diff options
Diffstat (limited to '_prototype')
| -rw-r--r-- | _prototype/.gitignore | 5 | ||||
| -rw-r--r-- | _prototype/Cargo.lock | 238 | ||||
| -rw-r--r-- | _prototype/Cargo.toml | 28 | ||||
| -rw-r--r-- | _prototype/LICENSE-MIT | 9 | ||||
| -rw-r--r-- | _prototype/README.md | 153 | ||||
| -rw-r--r-- | _prototype/README_zh_CN.md | 154 | ||||
| -rw-r--r-- | _prototype/converter/Cargo.toml | 11 | ||||
| -rw-r--r-- | _prototype/converter/src/bin/mdialogc.rs | 95 | ||||
| -rw-r--r-- | _prototype/converter/src/error.rs | 121 | ||||
| -rw-r--r-- | _prototype/converter/src/lib.rs | 5 | ||||
| -rw-r--r-- | _prototype/converter/src/macros.rs | 33 | ||||
| -rw-r--r-- | _prototype/converter/src/parse.rs | 1303 | ||||
| -rw-r--r-- | _prototype/converter/src/syntax_checker.rs | 221 | ||||
| -rw-r--r-- | _prototype/converter/src/utils.rs | 1 | ||||
| -rw-r--r-- | _prototype/converter/src/utils/path_fmt.rs | 123 | ||||
| -rw-r--r-- | _prototype/converter/usage.txt | 6 | ||||
| -rw-r--r-- | _prototype/converter/version.txt | 2 | ||||
| -rw-r--r-- | _prototype/player/Cargo.toml | 6 | ||||
| -rw-r--r-- | _prototype/player/src/lib.rs | 3 | ||||
| -rw-r--r-- | _prototype/src/lib.rs | 572 |
20 files changed, 3089 insertions, 0 deletions
diff --git a/_prototype/.gitignore b/_prototype/.gitignore new file mode 100644 index 0000000..08130ca --- /dev/null +++ b/_prototype/.gitignore @@ -0,0 +1,5 @@ +/target +.temp +*.md +!README.md +!README_zh_CN.md diff --git a/_prototype/Cargo.lock b/_prototype/Cargo.lock new file mode 100644 index 0000000..1d88bed --- /dev/null +++ b/_prototype/Cargo.lock @@ -0,0 +1,238 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "markdialog" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "sha2", + "syn", +] + +[[package]] +name = "markdialog_converter" +version = "0.1.0" +dependencies = [ + "colored", + "regex", + "sha2", + "strip-ansi-escapes", + "unicode-width", +] + +[[package]] +name = "markdialog_player" +version = "0.1.0" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/_prototype/Cargo.toml b/_prototype/Cargo.toml new file mode 100644 index 0000000..64a5913 --- /dev/null +++ b/_prototype/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "markdialog" +edition = "2024" +version = "0.1.0" +authors = ["Weicao-CatilGrass"] +description = "Write your story with Markdown!" +repository = "https://github.com/CatilGrass/MarkDialog" +license-file = "LICENSE-MIT" +readme = "README.md" +keywords = ["dialog", "markdown"] +categories = ["game-development"] +publish = true + +[workspace] +package.version = "0.1.0" +members = [ + "player", # Dialog Player + "converter" # Markdown Converter +] + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full", "visit-mut"] } +quote = "1.0" +proc-macro2 = "1.0" +sha2 = "0.10" diff --git a/_prototype/LICENSE-MIT b/_prototype/LICENSE-MIT new file mode 100644 index 0000000..4ff38a3 --- /dev/null +++ b/_prototype/LICENSE-MIT @@ -0,0 +1,9 @@ +# The MIT License (MIT) + +Copyright © 2026 Weicao-CatilGrass + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/_prototype/README.md b/_prototype/README.md new file mode 100644 index 0000000..4b74016 --- /dev/null +++ b/_prototype/README.md @@ -0,0 +1,153 @@ +# MarkDialog + +> Write your story with Markdown! + +## Some Ramblings + +This is a personal learning project of mine. To quickly achieve the desired effect, the project experimentally uses **Vibe Coding**. + +## Introduction + +MarkDialog is a **paradigm for writing textual narratives**. It defines how to use **Markdown** to describe dialogue for text adventure games. + +It allows you to: + +- Use **level-six headings** to represent characters. +- Use **headings** to represent paragraphs. +- Use **hyperlinks** to represent jumps. +- Use **code blocks** to control speed, visuals, and interact with game content. + +> [!NOTE] +> If you want to learn about MarkDialog syntax, click here. +> +> [MarkDialog Example Article](#example-article) + +## Toolchain + +MarkDialog provides a compilation tool `mdialogc`, which can compile your **Markdown** files into an intermediate language for fast playback or execution on any frontend. + +```bash +# Execution, will output YourMarkdown.dialog +mdialogc -i YourMarkdown.md +``` + +Currently supported frontends: + +- [Rust](#rust-example) + +## Other + +#### Example Article + +```markdown +# Dialogue + +###### Alice + +Good morning, Bob. + +Have you had breakfast? + +- [Of course I have!](#Bob-has-had-breakfast) +- [Not yet.](#Bob-has-not-had-breakfast) +- [I'm not hungry.](#Bob-is-not-hungry) + +## Bob-has-had-breakfast + +###### Bob + +I certainly **had breakfast**! + +###### Alice + +*Oh.* I was hoping you'd join me. + +###### Narrator + +(Story ends) + +## Bob-has-not-had-breakfast + +###### Bob + +Not yet. + +###### Alice + +[Great! Want to go get breakfast together?](#Alice-asks-to-eat-breakfast-together) + +## Bob-is-not-hungry + +###### Bob + +*I'm actually not hungry*? + +###### Alice + +Never mind then, what a pity today. (*muttering quietly*) + +###### Narrator + +(Story ends) + +## Alice-asks-to-eat-breakfast-together + +###### Bob + +Let me think... + +- Let's go! +- No, I'm [not hungry](#Bob-is-not-hungry) + +###### Alice + +Great! Let's go! + +###### Narrator + +(And so Bob and Alice had breakfast together) + +(Story ends) +``` + +#### Rust Example + +```toml +# Cargo.toml +[package] +name = "your_proj" +version = "0.1.0" +edition = "2024" + +[dependencies] +markdialog = "0.1" +``` + +```rust +// main.rs +use markdialog::{markdialog, step}; + +markdialog!(my = "alice_and_bob.dialog"); + +fn main() { + let my_step = my::get_step(step!("Bob-has-had-breakfast")).unwrap(); + let sentence = my_step.sentences[0]; + println!( + "{} said : \"{}\"", + sentence.character.unwrap_or_default(), + sentence + .content_tokens + .iter() + .map(|token| match token { + my::Token::Text(t) => t, + my::Token::BoldText(t) => t, + my::Token::ItalicText(t) => t, + my::Token::BoldItalicText(t) => t, + _ => "", + } + .to_string()) + .collect::<Vec<String>>() + .join("") + ) +} +``` diff --git a/_prototype/README_zh_CN.md b/_prototype/README_zh_CN.md new file mode 100644 index 0000000..49d30a5 --- /dev/null +++ b/_prototype/README_zh_CN.md @@ -0,0 +1,154 @@ +# MarkDialog + +> 用 Markdown 来书写您的剧情! + +## 一些碎碎语 + +这是一个我个人用于学习的项目,为了快速实现我想要的效果,项目中实验性地使用了 **Vibe Coding** + +## 介绍 + +MarkDialog 是 **书写文字剧情的范式**,它定义了如何用 **Markdown** 来描述文字冒险游戏的对话。 + +它允许你: + +- 使用 **六级标题** 表示角色 +- 使用 **标题** 表示段落 +- 使用 **超链接** 表示跳转 +- 使用 **代码块** 来控制速度、画面,与游戏内容互动 + +> [!NOTE] +> 如果你想了解 Markdialog 的语法,可以点击此处 +> +> [Markdialog 示例文章](#示例文章) + +## 工具链 + +MarkDialog 提供了编译工具 `mdialogc`,它可以将您的 **Markdown** 文件编译成中间语言,以快速地在任何前端播放或执行。 + +```bash +# 执行,将会输出 YourMarkdown.dialog +mdialogc -i YourMarkdown.md +``` + +目前支持的前端: + +- [Rust](#Rust 示例) + +## 其他 + +#### 示例文章 + +```markdown +# 对话 + +###### 爱丽丝 + +早上好,鲍勃。 + +你吃早饭了么? + +- [当然吃了!](#鲍勃吃过了早饭) +- [并没。](#鲍勃还没吃早饭) +- [我还不饿。](#鲍勃并不饿) + +## 鲍勃吃过了早饭 + +###### 鲍勃 + +我当然**吃了早饭**! + +###### 爱丽丝 + +*哦。*我还想你跟我一起去吃呢。 + +###### 旁白 + +(剧情结束) + +## 鲍勃还没吃早饭 + +###### 鲍勃 + +并没吃。 + +###### 爱丽丝 + +[好啊!一起去吃早饭么?](#爱丽丝询问是否一起吃早饭) + +## 鲍勃并不饿 + +###### 鲍勃 + +*我其实不饿*? + +###### 爱丽丝 + +那算了,今天太遗憾了。(*小声嘟囔*) + +###### 旁白 + +(剧情结束) + +## 爱丽丝询问是否一起吃早饭 + +###### 鲍勃 + +让我想想。。。 + +- 走吧! +- 算了,我[并不饿](#鲍勃并不饿) + +###### 爱丽丝 + +好啊!走吧! + +###### 旁白 + +(于是鲍勃和爱丽丝一起吃了早饭) + +(剧情结束) +``` + +#### Rust 示例 + +```toml +# Cargo.toml +[package] +name = "your_proj" +version = "0.1.0" +edition = "2024" + +[dependencies] +markdialog = "0.1" +``` + +```rust +// main.rs +use markdialog::{markdialog, step}; + +markdialog!(my = "alice_and_bob.dialog"); + +fn main() { + let my_step = my::get_step(step!("鲍勃吃过了早饭")).unwrap(); + let sentence = my_step.sentences[0]; + println!( + "{} said : \"{}\"", + sentence.character.unwrap_or_default(), + sentence + .content_tokens + .iter() + .map(|token| match token { + my::Token::Text(t) => t, + my::Token::BoldText(t) => t, + my::Token::ItalicText(t) => t, + my::Token::BoldItalicText(t) => t, + _ => "", + } + .to_string()) + .collect::<Vec<String>>() + .join("") + ) +} + +``` diff --git a/_prototype/converter/Cargo.toml b/_prototype/converter/Cargo.toml new file mode 100644 index 0000000..ce44af9 --- /dev/null +++ b/_prototype/converter/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "markdialog_converter" +edition = "2024" +version.workspace = true + +[dependencies] +colored = "3.0" +strip-ansi-escapes = "0.2.1" +unicode-width = "0.2" +regex = "1.12" +sha2 = "0.10" diff --git a/_prototype/converter/src/bin/mdialogc.rs b/_prototype/converter/src/bin/mdialogc.rs new file mode 100644 index 0000000..ebe7804 --- /dev/null +++ b/_prototype/converter/src/bin/mdialogc.rs @@ -0,0 +1,95 @@ +use markdialog_converter::{ + error::{Exit, handle_exit}, + parse::parse, + special_argument, special_flag, +}; +use std::{path::PathBuf, str::FromStr}; + +fn process() -> Result<(), Exit> { + let mut args: Vec<String> = std::env::args().skip(1).collect(); + + let help = special_flag!(args, "--help") || special_flag!(args, "-h"); + let version = special_flag!(args, "--version") || special_flag!(args, "-v"); + + if version { + let version = include_str!("../../version.txt"); + println!("{}", version.trim()); + return Err(Exit::Code(0)); + } + + if help || args.len() < 1 { + let usage = include_str!("../../usage.txt"); + println!("{}", usage.trim()); + return Err(Exit::Code(0)); + } + + let input_file = get_input_file(&mut args)?; + let output_ir_file = get_output_ir_file(&mut args)?.unwrap_or_else(|| { + let mut path = input_file.clone(); + if let Some(file_name) = path.file_name() { + let mut new_name = std::ffi::OsString::new(); + new_name.push(file_name); + // Change extension to .dialog + path.set_extension("dialog"); + } else { + path.set_file_name("ir.dialog"); + } + path + }); + + parse(input_file, output_ir_file)?; + + Ok(()) +} + +fn get_input_file(args: &mut Vec<String>) -> Result<PathBuf, Exit> { + let input = match special_argument!(args, "--input") { + Some(i) => i, + None => match special_argument!(args, "-i") { + Some(i) => i, + None => { + eprintln!("Missing required input argument. Use --input or -i."); + std::process::exit(2); + } + }, + }; + + let input_file = PathBuf::from_str(&input).map_err(|_| { + eprintln!("Invalid file path `{}`!", input); + Exit::Code(2) + })?; + + Ok(input_file) +} + +fn get_output_ir_file(args: &mut Vec<String>) -> Result<Option<PathBuf>, Exit> { + let input = match special_argument!(args, "--output") { + Some(i) => Some(i), + None => match special_argument!(args, "-o") { + Some(i) => Some(i), + None => None, + }, + }; + + match input { + Some(i) => { + let input_file = PathBuf::from_str(&i).map_err(|_| { + eprintln!("Invalid file path `{}`!", i); + return Exit::Code(2); + })?; + Ok(Some(input_file)) + } + None => Ok(None), + } +} + +fn main() { + // Init colored + #[cfg(windows)] + colored::control::set_virtual_terminal(true).unwrap(); + + match process() { + Ok(_) => {} + Err(e) => handle_exit(e), + } +} diff --git a/_prototype/converter/src/error.rs b/_prototype/converter/src/error.rs new file mode 100644 index 0000000..b594165 --- /dev/null +++ b/_prototype/converter/src/error.rs @@ -0,0 +1,121 @@ +use std::{i64, path::PathBuf, process::exit}; + +use colored::Colorize; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug)] +pub enum Exit { + Code(i32), + IoError(std::io::Error), + FileNotFound(PathBuf), + SyntaxError { + content: String, + reason: String, + line: i64, + begin: i64, + end: i64, + }, + DuplicateMarker(String), + CycleDependency(PathBuf), +} + +impl From<std::io::Error> for Exit { + fn from(error: std::io::Error) -> Self { + Exit::IoError(error) + } +} + +pub fn handle_exit(e: Exit) { + match e { + Exit::Code(code) => exit(code), + Exit::IoError(error) => print_parse_error(error.to_string()), + Exit::FileNotFound(path_buf) => { + eprintln!("File `{}` not found!", path_buf.display()); + exit(1) + } + Exit::SyntaxError { + content, + reason, + line, + begin, + end, + } => { + print_syntax_error(content, reason, line, begin, end); + } + Exit::DuplicateMarker(marker) => { + eprintln!("Duplicate marker `{}` found!", marker); + exit(1) + } + Exit::CycleDependency(dialog) => { + eprintln!("Dialog `{}` depends on itself!", dialog.display()); + exit(1) + } + } +} + +fn print_parse_error(content: impl AsRef<str>) { + eprintln!("Parse Error !"); + eprintln!("{}", content.as_ref().trim()); + exit(1); +} + +macro_rules! line { + ($line:expr, $N:expr) => { + if $line + $N <= 0 { + " ".to_string() + } else { + ($line + $N).to_string() + } + }; +} + +pub fn print_syntax_error(content: String, reason: String, line: i64, begin: i64, end: i64) { + let content_len = content.width() as i64; + let end = end.clamp(begin, content_len); + + eprintln!("{}", "Parse Failed: Syntax Error".bright_yellow()); + eprintln!("{}{}", line!(line, -1), "|"); + + let before: String = content.chars().take(begin.max(0) as usize).collect(); + let highlight_len = (end - begin).max(1) as usize; + let highlight: String = content + .chars() + .skip(begin.max(0) as usize) + .take(highlight_len) + .collect(); + let after: String = content + .chars() + .skip((begin.max(0) + highlight_len as i64) as usize) + .collect(); + + eprintln!( + "{}{} {}{}{}", + line.to_string().cyan(), + "|".cyan(), + before.cyan(), + highlight.bright_cyan(), + after.cyan() + ); + + let prefix_chars: String = content.chars().take(begin.max(0) as usize).collect(); + let prefix_width = prefix_chars.width() as usize; + + eprintln!( + "{}{} {}", + line!(line, 1), + "|", + format!( + "{}{}____ {}", + " ".repeat(prefix_width), + "^".repeat(((end - begin).max(1)) as usize), + reason + ) + .bright_cyan() + ); + eprintln!("{}{}", line!(line, 2), "|"); + eprintln!( + "{}", + "Please fix the issue and run the program again".bright_yellow() + ); + exit(1); +} diff --git a/_prototype/converter/src/lib.rs b/_prototype/converter/src/lib.rs new file mode 100644 index 0000000..d7caac3 --- /dev/null +++ b/_prototype/converter/src/lib.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod macros; +pub mod parse; +pub mod syntax_checker; +pub mod utils; diff --git a/_prototype/converter/src/macros.rs b/_prototype/converter/src/macros.rs new file mode 100644 index 0000000..894b3f4 --- /dev/null +++ b/_prototype/converter/src/macros.rs @@ -0,0 +1,33 @@ +#[macro_export] +macro_rules! special_flag { + ($args:expr, $flag:expr) => {{ + let flag = $flag; + let found = $args.iter().any(|arg| arg == flag); + $args.retain(|arg| arg != flag); + found + }}; +} + +#[macro_export] +macro_rules! special_argument { + ($args:expr, $flag:expr) => {{ + let flag = $flag; + let mut value: Option<String> = None; + let mut i = 0; + while i < $args.len() { + if $args[i] == flag { + if i + 1 < $args.len() { + value = Some($args[i + 1].clone()); + $args.remove(i + 1); + $args.remove(i); + } else { + value = None; + $args.remove(i); + } + break; + } + i += 1; + } + value + }}; +} diff --git a/_prototype/converter/src/parse.rs b/_prototype/converter/src/parse.rs new file mode 100644 index 0000000..292a761 --- /dev/null +++ b/_prototype/converter/src/parse.rs @@ -0,0 +1,1303 @@ +use std::path::PathBuf; + +use crate::{ + error::Exit, + syntax_checker::{check_duplicate_marker, check_markdown_syntax}, +}; + +pub fn parse(input: PathBuf, ir_output: PathBuf) -> Result<(), Exit> { + let result = std::fs::read_to_string(&input)?; + + check_markdown_syntax(&result)?; + + let result = unwrap_includes::proc(result, input)?; + + check_duplicate_marker(&result)?; + + let result = markdown_cleanup::proc(result)?; + let result = markdown_jump_fix::proc(result)?; + let result = markdown_marker_rename::proc(result)?; + let result = markdown_struct_build::proc(result)?; + let result = markdown_strip_invalid_jump::proc(result)?; + let result = markdown_convert_image::proc(result)?; + let result = markdown_apply_codes::proc(result)?; + let result = markdown_split_and_encode::proc(result)?; + + std::fs::write(&ir_output, result)?; + Ok(()) +} + +pub mod unwrap_includes { + use crate::{error::Exit, utils::path_fmt::format_path}; + use regex::Regex; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + + /// Expand text includes of [[markdown]] (searches for markdown.md) and image paths + pub fn proc(input: String, self_path: PathBuf) -> Result<String, Exit> { + let mut stack = Vec::<PathBuf>::new(); + let mut cache = HashMap::<String, PathBuf>::new(); + let mut img_cache = HashMap::<String, PathBuf>::new(); + let root_path = self_path.clone(); + expand_recursive( + input, + &self_path, + &root_path, + &mut stack, + &mut cache, + &mut img_cache, + ) + } + + fn expand_recursive( + content: String, + current_path: &Path, + root_path: &Path, + stack: &mut Vec<PathBuf>, + cache: &mut HashMap<String, PathBuf>, + img_cache: &mut HashMap<String, PathBuf>, + ) -> Result<String, Exit> { + let mut output = String::new(); + let mut in_code_block = false; + let obsidian_image_re = Regex::new(r"!\[\[([^\]]+)\]\]").unwrap(); + + let current_norm = format_path(current_path)?; + + if stack.contains(¤t_norm) { + return Err(Exit::CycleDependency(current_norm)); + } + + stack.push(current_norm.clone()); + + for line in content.lines() { + if line.trim().starts_with("```") { + in_code_block = !in_code_block; + output.push_str(line); + output.push('\n'); + continue; + } + + if in_code_block { + output.push_str(line); + output.push('\n'); + continue; + } + + if let Some(include_name) = extract_include(line) { + let include_abs = if let Some(cached_path) = cache.get(include_name) { + cached_path.clone() + } else { + let base_dir = current_path.parent().unwrap(); + let mut queue = vec![base_dir.to_path_buf()]; + let mut visited = std::collections::HashSet::new(); + let mut found_path = None; + + while let Some(dir) = queue.pop() { + if visited.contains(&dir) { + continue; + } + visited.insert(dir.clone()); + + let candidate1 = dir.join(include_name); + if candidate1.exists() + && candidate1.extension().map_or(false, |ext| ext == "md") + { + found_path = Some(format_path(&candidate1)?); + break; + } + + // Try add .md extension + let candidate2 = dir.join(format!("{}.md", include_name)); + if candidate2.exists() { + found_path = Some(format_path(&candidate2)?); + break; + } + + if let Some(parent) = dir.parent() { + queue.push(parent.to_path_buf()); + } + + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + queue.push(entry.path()); + } + } + } + } + } + + match found_path { + Some(path) => { + cache.insert(include_name.to_string(), path.clone()); + path + } + None => { + return Err(Exit::FileNotFound( + format!( + "{} or {}.md (searched from {:?})", + include_name, include_name, base_dir + ) + .into(), + )); + } + } + }; + + let include_content = std::fs::read_to_string(&include_abs).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Exit::FileNotFound(include_abs.clone()) + } else { + Exit::IoError(e) + } + })?; + + let expanded = expand_recursive( + include_content, + &include_abs, + root_path, + stack, + cache, + img_cache, + )?; + output.push_str(&expanded); + } else { + // Process image links + let mut processed_line = line.to_string(); + + // First process Obsidian image syntax ![[...]] + let mut obsidian_matches: Vec<(usize, usize, String)> = Vec::new(); + + for caps in obsidian_image_re.captures_iter(line) { + let full_match = caps.get(0).unwrap(); + let image_ref = caps.get(1).unwrap().as_str(); + + // Try to get from img_cache first + let image_abs = if let Some(cached_path) = img_cache.get(image_ref) { + cached_path.clone() + } else { + // Start search from root directory for Obsidian syntax + let root_dir = root_path.parent().unwrap(); + let mut queue = vec![root_dir.to_path_buf()]; + let mut visited = std::collections::HashSet::new(); + let mut found_image_path = None; + + while let Some(dir) = queue.pop() { + if visited.contains(&dir) { + continue; + } + visited.insert(dir.clone()); + + // Check if file exists in this directory + let candidate = dir.join(image_ref); + if candidate.exists() { + found_image_path = Some(format_path(&candidate)?); + break; + } + + // Add parent directory to queue (breadth-first upward) + if let Some(parent) = dir.parent() { + queue.push(parent.to_path_buf()); + } + + // Add subdirectories to queue (breadth-first downward) + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + queue.push(entry.path()); + } + } + } + } + } + + match found_image_path { + Some(path) => { + img_cache.insert(image_ref.to_string(), path.clone()); + path + } + None => { + // If image not found, keep the original syntax + continue; + } + } + }; + + // Store the replacement information + let start = full_match.start(); + let end = full_match.end(); + let new_image_markdown = format!("", image_abs.display()); + obsidian_matches.push((start, end, new_image_markdown)); + } + + // Apply Obsidian replacements in reverse order to maintain correct indices + for (start, end, replacement) in obsidian_matches.into_iter().rev() { + processed_line.replace_range(start..end, &replacement); + } + + output.push_str(&processed_line); + output.push('\n'); + } + } + + stack.pop(); + + Ok(output) + } + + fn extract_include(line: &str) -> Option<&str> { + line.trim() + .strip_prefix("[[") + .and_then(|s| s.strip_suffix("]]")) + } +} + +pub mod markdown_cleanup { + use crate::error::Exit; + + /// Clean Markdown + /// 1. Remove blockquotes + /// 2. Remove empty lines + /// 3. Trim each line + pub fn proc(i: String) -> Result<String, Exit> { + let lines = i.lines(); + let mut cleaned = Vec::new(); + + for line in lines { + if line.starts_with('>') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + cleaned.push(trimmed.to_string()); + } + + Ok(cleaned.join("\n")) + } + + #[cfg(test)] + mod test_clean_markdown { + use super::*; + + #[test] + fn test_clean_markdown_removes_blockquotes() { + let input = "> This is a blockquote\nNormal text\n> Another blockquote".to_string(); + let expected = "Normal text".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_removes_empty_lines() { + let input = "Line 1\n\n\nLine 2\n\n".to_string(); + let expected = "Line 1\nLine 2".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_trims_lines() { + let input = " Line 1 \n\tLine 2\t\n".to_string(); + let expected = "Line 1\nLine 2".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_combined() { + let input = "> Blockquote\n\n Line 1 \n> Another\n\nLine 2\n\n".to_string(); + let expected = "Line 1\nLine 2".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_empty_input() { + let input = "".to_string(); + let expected = "".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_only_blockquotes() { + let input = "> Quote 1\n> Quote 2".to_string(); + let expected = "".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_clean_markdown_only_whitespace() { + let input = " \n\t\n ".to_string(); + let expected = "".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + } +} + +pub mod markdown_jump_fix { + use crate::error::Exit; + + /// Fix jump syntax in each line + /// 1. Correct the following syntax + /// ```ignore + /// - It's [Item](#Mark) + /// > corrected to + /// - It's Item [](#Mark) + /// ``` + /// + /// 2. If there are multiple options, take the first one + /// ```ignore + /// - There might be two options: [A](#A) and [B](#B)! + /// > corrected to + /// - There might be two options: A and B! [](#A) + /// ``` + pub fn proc(i: String) -> Result<String, Exit> { + let mut result = String::new(); + + for line in i.lines() { + let (processed_content, first_link_dest) = process_line_content(line); + let processed_line = format_line_with_link(processed_content, first_link_dest); + let final_line = convert_ordered_list_marker(processed_line); + + result.push_str(&final_line); + result.push('\n'); + } + + if result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } + + pub fn process_line_content(line: &str) -> (String, Option<String>) { + // Check if line is an image line (starts with "![") + if line.starts_with("![") { + // Return the original line unchanged with no link destination + return (line.to_string(), None); + } + + let mut processed = String::new(); + let mut chars = line.chars().peekable(); + let mut first_link_dest = None; + let mut has_link = false; + + while let Some(ch) = chars.next() { + if ch == '[' { + if let Some((link_text, link_dest, remaining_chars)) = helper_parse_link(&mut chars) + { + processed.push_str(&link_text); + if !has_link { + first_link_dest = Some(link_dest); + has_link = true; + } + chars = remaining_chars; + continue; + } else { + // Invalid + processed.push(ch); + } + } else { + processed.push(ch); + } + } + + (processed, first_link_dest) + } + + pub fn helper_parse_link<'a>( + chars: &mut std::iter::Peekable<std::str::Chars<'a>>, + ) -> Option<(String, String, std::iter::Peekable<std::str::Chars<'a>>)> { + let mut link_text = String::new(); + + while let Some(&ch) = chars.peek() { + chars.next(); + if ch == ']' { + break; + } + link_text.push(ch); + } + + if chars.next() != Some('(') || chars.next() != Some('#') { + return None; + } + + let mut link_dest = String::new(); + while let Some(ch) = chars.next() { + if ch == ')' { + break; + } + link_dest.push(ch); + } + + let cleaned_dest = link_dest.trim().replace(' ', "").replace('#', ""); + + Some((link_text, cleaned_dest, chars.clone())) + } + + pub fn format_line_with_link(content: String, link_dest: Option<String>) -> String { + match link_dest { + Some(dest) if !dest.trim().is_empty() => { + format!("{} [](#{})", content.trim_end(), dest.trim()) + .trim() + .to_string() + } + _ => content, + } + } + + pub fn convert_ordered_list_marker(line: String) -> String { + let trimmed = line.trim_start(); + + if let Some(_rest) = trimmed.strip_prefix(|c: char| c.is_ascii_digit()) { + let mut chars = trimmed.chars(); + let mut digit_count = 0; + + while let Some(c) = chars.next() { + if c.is_ascii_digit() { + digit_count += 1; + } else { + break; + } + } + + if digit_count > 0 { + let rest_after_digits = &trimmed[digit_count..]; + if let Some(content) = rest_after_digits.strip_prefix(". ") { + return format!("- {}", content); + } + } + } + + line + } + + #[cfg(test)] + mod test_fix_mark_jump { + use super::*; + + #[test] + fn test_fix_mark_jump_single_link() { + let input = "- It's [Item](#Mark)".to_string(); + let expected = "- It's Item [](#Mark)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_multiple_links_takes_first() { + let input = "- There might be two options: [A](#A) and [B](#B)!".to_string(); + let expected = "- There might be two options: A and B! [](#A)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_no_link() { + let input = "- Just a normal line".to_string(); + let expected = "- Just a normal line".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_empty_line() { + let input = "".to_string(); + let expected = "".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_multiple_lines() { + let input = "- First [Item](#First)\n- Second [Item](#Second)".to_string(); + let expected = "- First Item [](#First)\n- Second Item [](#Second)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_link_at_end() { + let input = "- End with [link](#target)".to_string(); + let expected = "- End with link [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_link_at_beginning() { + let input = "- [Start](#target) with link".to_string(); + let expected = "- Start with link [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_link_in_middle() { + let input = "- Text [middle](#target) text".to_string(); + let expected = "- Text middle text [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_ordered_list_conversion() { + let input = "1. [Item](#target)".to_string(); + let expected = "- Item [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_ordered_list_multiple_digits() { + let input = "10. [Tenth](#target) item".to_string(); + let expected = "- Tenth item [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_mixed_ordered_and_unordered() { + let input = "1. [First](#first)\n- [Second](#second)\n2. [Third](#third)".to_string(); + let expected = + "- First [](#first)\n- Second [](#second)\n- Third [](#third)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_invalid_link_format() { + let input = "- Invalid [link format".to_string(); + let expected = "- Invalid [".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_link_with_spaces_in_target() { + let input = "- Link [text](# target#)".to_string(); + let expected = "- Link text [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_empty_link_text() { + let input = "- [](#target)".to_string(); + let expected = "- [](#target)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_only_whitespace() { + let input = " ".to_string(); + let expected = " ".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + + #[test] + fn test_fix_mark_jump_complex_multiple_links() { + let input = "- Choose [A](#A), [B](#B), or [C](#C)!".to_string(); + let expected = "- Choose A, B, or C! [](#A)".to_string(); + let Ok(result) = proc(input) else { + panic!("Parse error!"); + }; + assert_eq!(result, expected); + } + } +} + +pub mod markdown_marker_rename { + use regex::Regex; + use sha2::{Digest, Sha256}; + + use crate::error::Exit; + + /// Replace marker names: replace heading text and link anchors with corresponding SHA256 + /// + /// Example: + /// ```ignore + /// # Original text + /// # Chapter Title + /// - Jump to [Chapter Title](#Chapter Title) + /// + /// # After processing + /// # a1b2c3d4 + /// - Jump to [](#a1b2c3d4) + /// ``` + pub fn proc(i: String) -> Result<String, Exit> { + let mut result = i; + + let heading_re = Regex::new(r"^(#{1,5})\s+(.+)$").unwrap(); + let mut heading_map = std::collections::HashMap::new(); + + for line in result.lines() { + if let Some(caps) = heading_re.captures(line) { + let heading_text = caps[2].trim().to_string(); + let hash = format!("{:x}", Sha256::digest(heading_text.as_bytes())); + let short_hash = &hash[..8]; + heading_map.insert(heading_text, short_hash.to_string()); + } + } + + let mut lines: Vec<String> = Vec::new(); + for line in result.lines() { + if let Some(caps) = heading_re.captures(line) { + let level = &caps[1]; + let heading_text = caps[2].trim(); + + if let Some(hash) = heading_map.get(heading_text) { + lines.push(format!("{} {}", level, hash)); + } else { + lines.push(line.to_string()); + } + } else { + lines.push(line.to_string()); + } + } + result = lines.join("\n"); + + let link_re = Regex::new(r"\[\]\(#([^)]+)\)").unwrap(); + result = link_re + .replace_all(&result, |caps: ®ex::Captures| { + let anchor_name = &caps[1]; + if let Some(hash) = heading_map.get(anchor_name) { + format!("[](#{})", hash) + } else { + let hash = format!("{:x}", Sha256::digest(anchor_name.as_bytes())); + let short_hash = &hash[..8]; + format!("[](#{})", short_hash) + } + }) + .to_string(); + + Ok(result) + } +} + +pub mod markdown_struct_build { + use regex::Regex; + + use crate::error::Exit; + + /// Split content into Step + Sentence structure + pub fn proc(input: String) -> Result<String, Exit> { + let mut result = String::new(); + let mut current_marker = String::new(); + let mut current_step_id = 0; + let mut current_character = String::new(); + let mut has_no_switch_flag = false; + + let mut code_record_mode = false; + let mut option_record_mode = false; + + let mut sentences_buffer = String::new(); + for line in input.split("\n") { + // Record code + if code_record_mode { + // If code block marker is found again, end code recording + if line.starts_with("```") && code_record_mode { + sentences_buffer.push_str("\n"); + code_record_mode = false; + continue; + } + sentences_buffer.push_str(format!("`{}`", line).as_str()); + continue; + } + + // Record options + if option_record_mode { + // Still an option, continue appending + if line.starts_with("- ") { + let (sentence, next) = get_jump_from_line(line); + let next = if let Some(next) = next { + format!("->[#{}_0]", next) + } else { + next_flag(current_marker.as_str(), current_step_id) + }; + let option_line = format!( + "{}[{}]{}", + character(¤t_character, has_no_switch_flag), + sentence, + next + ); + sentences_buffer.push_str(option_line.as_str()); + sentences_buffer.push('\n'); + continue; + } else { + // When ending option recording, create and advance one Step + result.push_str(step_line(current_marker.as_str(), current_step_id).as_str()); + result.push('\n'); + result.push_str(sentences_buffer.as_str()); + sentences_buffer.clear(); + current_step_id += 1; + // Clean "Has no switch flag" + has_no_switch_flag = false; + // Close option mode + option_record_mode = false; + // Do not continue here, proceed to process subsequent content + } + } + + // Refresh heading + if is_marker(line) { + current_marker = read_maker(line).to_string(); + current_step_id = 0; + continue; + } + + // Refresh character + if is_character(line) { + let (character, no_switch_flag) = read_character(line); + current_character = character.to_string(); + has_no_switch_flag = no_switch_flag; + continue; + } + + // Image recording + if line.starts_with('!') { + sentences_buffer.push_str(line); + sentences_buffer.push('\n'); + continue; + } + + // Start code recording + if line.starts_with("```") && !code_record_mode { + code_record_mode = true; + continue; + } + + // Option recording + if line.starts_with("- ") { + let (sentence, next) = get_jump_from_line(line); + let next = if let Some(next) = next { + format!("->[#{}_0]", next) + } else { + next_flag(current_marker.as_str(), current_step_id) + }; + let option_line = format!( + "{}[{}]{}", + character(¤t_character, has_no_switch_flag), + sentence, + next + ); + sentences_buffer.push_str(option_line.as_str()); + sentences_buffer.push('\n'); + + // Start option recording mode + if !option_record_mode { + option_record_mode = true; + } + continue; + } + + // Normal sentence + let (sentence, next) = get_jump_from_line(line); + let next = if let Some(next) = next { + format!("->[#{}_0]", next) + } else { + next_flag(current_marker.as_str(), current_step_id) + }; + let sentence_line = format!( + "{}[{}]{}", + character(¤t_character, has_no_switch_flag), + sentence, + next + ); + has_no_switch_flag = false; + + // Create and advance one Step + result.push_str(step_line(current_marker.as_str(), current_step_id).as_str()); + result.push('\n'); + result.push_str(sentences_buffer.as_str()); + sentences_buffer.clear(); + result.push_str(sentence_line.as_str()); + result.push('\n'); + current_step_id += 1; + } + + Ok(result) + } + + pub fn character(character: &str, has_no_switch_flag: bool) -> String { + let flag = if has_no_switch_flag { "*" } else { "" }; + format!("[{}{}{}]:", &flag, character, &flag) + } + + pub fn step_name(marker: &str, current_id: i64) -> String { + format!("{}_{}", marker, current_id) + } + + pub fn step_line(marker: &str, current_id: i64) -> String { + format!("@@@@@@@@@@ {}_{}", marker, current_id) + } + + pub fn next_flag(marker: &str, current_id: i64) -> String { + format!("->[#{}_{}]", marker, current_id + 1) + } + + pub fn is_marker(line: &str) -> bool { + line.starts_with("# ") + || line.starts_with("## ") + || line.starts_with("### ") + || line.starts_with("#### ") + || line.starts_with("##### ") + } + + pub fn read_maker(line: &str) -> &str { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + if trimmed.starts_with("# ") + || trimmed.starts_with("## ") + || trimmed.starts_with("### ") + || trimmed.starts_with("#### ") + || trimmed.starts_with("##### ") + { + let parts: Vec<&str> = trimmed.splitn(2, ' ').collect(); + if parts.len() == 2 { + return parts[1].trim(); + } + } + } + "" + } + + pub fn is_character(line: &str) -> bool { + line.starts_with("######") + } + + pub fn read_character(line: &str) -> (&str, bool) { + let trimmed = line.trim_start(); + if trimmed.starts_with("######") { + let parts: Vec<&str> = trimmed.splitn(2, ' ').collect(); + if parts.len() == 2 { + let character = parts[1].trim(); + if character.starts_with('*') && character.ends_with('*') { + let trimmed = character.trim_matches('*'); + return (trimmed.trim(), true); + } else { + return (character.trim(), false); + } + } + } + ("", false) + } + + pub fn get_jump_from_line(line: &str) -> (String, Option<String>) { + let pattern = r"\[\]\(#([^)]+)\)$"; + let re = Regex::new(pattern).unwrap(); + + if let Some(caps) = re.captures(line.trim_end()) { + let target = caps.get(1).unwrap().as_str(); + let line_without_jump = line + .trim_end() + .replace(&format!(" [](#{})", target), "") + .to_string(); + return ( + line_without_jump.trim_start_matches("- ").to_string(), + Some(format!("{}", target)), + ); + } + + (line.trim_start_matches("- ").to_string(), None) + } +} + +pub mod markdown_strip_invalid_jump { + use crate::error::Exit; + use regex::Regex; + + /// Strip all jumps that have not appeared + pub fn proc(input: String) -> Result<String, Exit> { + let lines: Vec<&str> = input.lines().collect(); + let mut valid_ids = std::collections::HashSet::new(); + + for line in &lines { + if line.starts_with("@@@@@@@@@@ ") { + let id = line.trim_start_matches("@@@@@@@@@@ ").trim(); + valid_ids.insert(id.to_string()); + } + } + + let mut result_lines = Vec::new(); + let link_re = Regex::new(r"\[#([^)]+)\]").unwrap(); + + for line in lines { + let processed_line = link_re.replace_all(line, |caps: ®ex::Captures| { + let id = &caps[1]; + if valid_ids.contains(id) { + format!("[#{}]", id) + } else { + "[]".to_string() + } + }); + result_lines.push(processed_line.to_string()); + } + + Ok(result_lines.join("\n")) + } +} + +pub mod markdown_convert_image { + use regex::Regex; + + use crate::error::Exit; + + /// Convert image lines to code lines + pub fn proc(input: String) -> Result<String, Exit> { + let mut result = String::new(); + let lines: Vec<&str> = input.lines().collect(); + let image_re = Regex::new(r"^!\[[^\]]*\]\(([^)]+)\)$").unwrap(); + + for line in lines { + if let Some(caps) = image_re.captures(line) { + let image_path = caps.get(1).unwrap().as_str(); + result.push_str(&format!("`image \"{}\"`\n", image_path)); + } else { + result.push_str(line); + result.push('\n'); + } + } + + // Remove trailing newline if present + if result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } +} + +pub mod markdown_apply_codes { + use crate::error::Exit; + + /// Apply code lines to sentences + pub fn proc(input: String) -> Result<String, Exit> { + let mut out = String::new(); + let lines: Vec<&str> = input.lines().collect(); + + let mut i = 0; + while i < lines.len() { + let line = lines[i]; + + if !line.trim_start().starts_with('`') { + out.push_str(line); + out.push('\n'); + i += 1; + continue; + } + + let mut code_buf = String::new(); + while i < lines.len() && { + let line: &str = lines[i]; + line.trim_start().starts_with('`') + } { + code_buf.push_str(lines[i].trim()); + i += 1; + } + + if i >= lines.len() + || !{ + let line: &str = lines[i]; + line.trim_start().starts_with('[') + } + { + continue; + } + + if i + 1 < lines.len() && { + let line: &str = lines[i + 1]; + line.trim_start().starts_with('[') + } { + continue; + } + + let merged = merge_code_into_sentence(&code_buf, lines[i]); + out.push_str(&merged); + out.push('\n'); + i += 1; + } + + Ok(out) + } + + fn merge_code_into_sentence(code: &str, sentence: &str) -> String { + if let Some(start) = sentence.find(":[") { + if let Some(_) = sentence[start + 2..].find(']') { + let content_start = start + 2; + + let mut result = String::new(); + result.push_str(&sentence[..content_start]); + result.push_str(code); + result.push_str(&sentence[content_start..]); + return result; + } + } + + sentence.to_string() + } +} + +pub mod markdown_split_and_encode { + use crate::error::Exit; + + /// Split sentences into embeddable tokens and perform Unicode encoding + pub fn proc(input: String) -> Result<String, Exit> { + let mut result = String::new(); + let lines: Vec<&str> = input.lines().collect(); + + for line in lines { + if line.starts_with('[') && line.contains("]:[") && line.contains("]->[") { + if let Some(start) = line.find("]:[") { + if let Some(end) = line.find("]->[") { + let content = &line[start + 3..end]; + let processed_content = process_sentence_content(content); + + let suffix = &line[end + 1..]; + + let char_end = start; + let char_start = 1; + let character = &line[char_start..char_end]; + let encoded_character = encode_unicode(character); + + // Build the new line with encoded character and processed content + let new_line = + format!("[{}]:{}{}", encoded_character, processed_content, suffix); + result.push_str(&format!("{}\n", new_line)); + continue; + } + } + } + result.push_str(&format!("{}\n", line)); + } + + if result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } + + fn process_sentence_content(content: &str) -> String { + let mut result = String::new(); + let mut chars = content.chars().peekable(); + let mut current_text = String::new(); + let mut in_code = false; + let mut in_bold = false; + let mut in_italic = false; + let mut code_buffer = String::new(); + let mut backticks_count = 0; + + while let Some(ch) = chars.next() { + match ch { + '`' => { + backticks_count += 1; + if backticks_count == 1 { + // Start of code block + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[text:[{}]]", encoded_text)); + current_text.clear(); + } + code_buffer.push(ch); + in_code = true; + } else if backticks_count == 2 && in_code { + // End of code block + code_buffer.push(ch); + let encoded_code = encode_unicode(&code_buffer); + result.push_str(&format!("[code:[{}]]", encoded_code)); + code_buffer.clear(); + backticks_count = 0; + in_code = false; + } else if backticks_count == 1 && !in_code { + // Single backtick in text + current_text.push(ch); + } + } + '*' => { + if in_code { + code_buffer.push(ch); + continue; + } + + // Check for bold + if chars.peek() == Some(&'*') { + chars.next(); // Consume the second '*' + + // Check for bold_italic (***) + if chars.peek() == Some(&'*') { + chars.next(); // Consume the third '*' + + if in_bold && in_italic { + // End bold_italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[bold_italic:[{}]]", encoded_text)); + current_text.clear(); + } + in_bold = false; + in_italic = false; + } else { + // Start bold_italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[text:[{}]]", encoded_text)); + current_text.clear(); + } + in_bold = true; + in_italic = true; + } + } else { + // Handle ** (bold) + if in_bold && !in_italic { + // End bold + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[bold:[{}]]", encoded_text)); + current_text.clear(); + } + in_bold = false; + } else if in_italic { + // Currently in italic, encountering ** means we need to end italic and start bold_italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[italic:[{}]]", encoded_text)); + current_text.clear(); + } + in_italic = false; + in_bold = true; + } else { + // Start bold + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[text:[{}]]", encoded_text)); + current_text.clear(); + } + in_bold = true; + } + } + } else { + // Single * (italic) + if in_italic && !in_bold { + // End italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[italic:[{}]]", encoded_text)); + current_text.clear(); + } + in_italic = false; + } else if in_bold { + // Currently in bold, encountering * means we need to start bold_italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[bold:[{}]]", encoded_text)); + current_text.clear(); + } + in_italic = true; + } else { + // Start italic + if !current_text.is_empty() { + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[text:[{}]]", encoded_text)); + current_text.clear(); + } + in_italic = true; + } + } + } + _ => { + if in_code { + code_buffer.push(ch); + } else { + current_text.push(ch); + } + } + } + } + + // Handle any remaining text + if !code_buffer.is_empty() { + let encoded_code = encode_unicode(&code_buffer); + result.push_str(&format!("[code:[{}]]", encoded_code)); + } + + if !current_text.is_empty() { + let style = match (in_bold, in_italic) { + (true, true) => "bold_italic", + (true, false) => "bold", + (false, true) => "italic", + (false, false) => "text", + }; + let encoded_text = encode_unicode(¤t_text); + result.push_str(&format!("[{}:[{}]]", style, encoded_text)); + } + + result + } + + fn encode_unicode(s: &str) -> String { + let mut result = String::new(); + for ch in s.chars() { + let code = ch as u32; + if code <= 0x7F { + result.push(ch); + } else { + result.push_str(&format!("\\u{:X}", code)); + } + } + result + } +} diff --git a/_prototype/converter/src/syntax_checker.rs b/_prototype/converter/src/syntax_checker.rs new file mode 100644 index 0000000..52021c7 --- /dev/null +++ b/_prototype/converter/src/syntax_checker.rs @@ -0,0 +1,221 @@ +use regex::Regex; + +use crate::error::Exit; + +pub fn check_markdown_syntax(i: &String) -> Result<(), Exit> { + let mut stack = Vec::new(); + let lines: Vec<&str> = i.lines().collect(); + let mut anchors = Vec::new(); + let mut heading_ids = Vec::new(); + + for (line_num, line) in lines.iter().enumerate() { + let line_num = line_num as i64 + 1; + + // Check for headings to collect anchor IDs + if line.starts_with('#') { + let heading_text = line.trim_start_matches('#').trim(); + let id = heading_text + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect::<String>(); + if !id.is_empty() { + heading_ids.push(id); + } + } + + let mut chars = line.chars().enumerate().peekable(); + while let Some((pos, ch)) = chars.next() { + let pos = pos as i64 + 1; + + match ch { + '[' => { + // Check if it's a link or image + let is_image = chars.peek().map(|&(_, c)| c) == Some('!'); + if is_image { + chars.next(); // Skip '!' + } + stack.push(('['.to_string(), line_num, pos, is_image)); + } + ']' => { + if let Some((last, _l, b, is_image)) = stack.pop() { + if last != "[" { + return Err(Exit::SyntaxError { + content: line.to_string(), + reason: format!( + "Mismatched bracket: expected '[' but found '{}'", + last + ), + line: line_num, + begin: b, + end: pos, + }); + } + // Check if it's followed by '(' for a link + if chars.peek().map(|&(_, c)| c) == Some('(') { + chars.next(); // Skip '(' + // Look for closing ')' + let mut found = false; + let mut anchor_started = false; + let mut anchor = String::new(); + while let Some((_, c)) = chars.next() { + if c == ')' { + found = true; + break; + } + if c == '#' && !anchor_started { + anchor_started = true; + continue; + } + if anchor_started { + anchor.push(c); + } + } + if !found { + return Err(Exit::SyntaxError { + content: line.to_string(), + reason: "Link parentheses not closed".to_string(), + line: line_num, + begin: pos, + end: pos, + }); + } + if !anchor.is_empty() { + // Remove whitespace from anchor + let anchor = anchor.replace(|c: char| c.is_whitespace(), ""); + anchors.push((anchor, line_num, pos)); + } + } else if !is_image { + // It's a reference link, collect the anchor + // Check for anchor like [](#anchor) + if chars.peek().map(|&(_, c)| c) == Some('(') { + chars.next(); // Skip '(' + if chars.peek().map(|&(_, c)| c) == Some('#') { + chars.next(); // Skip '#' + let mut anchor = String::new(); + while let Some(&(_, c)) = chars.peek() { + if c == ')' { + break; + } + anchor.push(c); + chars.next(); + } + if !anchor.is_empty() { + // Remove whitespace from anchor + let anchor = + anchor.replace(|c: char| c.is_whitespace(), ""); + anchors.push((anchor, line_num, pos)); + } + } + } + } + } else { + return Err(Exit::SyntaxError { + content: line.to_string(), + reason: "Unmatched ']'".to_string(), + line: line_num, + begin: pos, + end: pos, + }); + } + } + '(' => { + // Check for standalone anchor like (#anchor) + if chars.peek().map(|&(_, c)| c) == Some('#') { + chars.next(); // Skip '#' + let mut anchor = String::new(); + while let Some(&(_, c)) = chars.peek() { + if c == ')' { + break; + } + anchor.push(c); + chars.next(); + } + if !anchor.is_empty() { + // Remove whitespace from anchor + let anchor = anchor.replace(|c: char| c.is_whitespace(), ""); + anchors.push((anchor, line_num, pos)); + } + } else { + stack.push(('('.to_string(), line_num, pos, false)); + } + } + ')' => { + if let Some((last, _l, b, _)) = stack.pop() { + if last != "(" { + return Err(Exit::SyntaxError { + content: line.to_string(), + reason: format!( + "Mismatched parenthesis: expected '(' but found '{}'", + last + ), + line: line_num, + begin: b, + end: pos, + }); + } + } else { + return Err(Exit::SyntaxError { + content: line.to_string(), + reason: "Unmatched ')'".to_string(), + line: line_num, + begin: pos, + end: pos, + }); + } + } + '`' => { + // Check for backticks + let mut count = 1; + while chars.peek().map(|&(_, c)| c) == Some('`') { + count += 1; + chars.next(); + } + let marker = "`".repeat(count); + + if let Some((last, _, _, _)) = stack.last() { + if last == &marker { + stack.pop(); + } else { + stack.push((marker.clone(), line_num, pos, false)); + } + } else { + stack.push((marker, line_num, pos, false)); + } + } + _ => {} + } + } + } + + // Check for unclosed brackets/parentheses + if let Some((last, line, begin, _)) = stack.pop() { + return Err(Exit::SyntaxError { + content: lines[(line - 1) as usize].to_string(), + reason: format!("Unclosed '{}'", last), + line, + begin, + end: begin, + }); + } + + Ok(()) +} + +/// Check for duplicate markers +pub fn check_duplicate_marker(input: &String) -> Result<(), Exit> { + let mut seen = std::collections::HashSet::new(); + let heading_re = Regex::new(r"^(#{1,5})\s+(.+)$").unwrap(); + + for line in input.lines() { + if let Some(caps) = heading_re.captures(line) { + let heading_text = caps[2].trim().to_string(); + if seen.contains(&heading_text) { + return Err(Exit::DuplicateMarker(heading_text)); + } + seen.insert(heading_text); + } + } + + Ok(()) +} diff --git a/_prototype/converter/src/utils.rs b/_prototype/converter/src/utils.rs new file mode 100644 index 0000000..0fbb516 --- /dev/null +++ b/_prototype/converter/src/utils.rs @@ -0,0 +1 @@ +pub mod path_fmt; diff --git a/_prototype/converter/src/utils/path_fmt.rs b/_prototype/converter/src/utils/path_fmt.rs new file mode 100644 index 0000000..8750db6 --- /dev/null +++ b/_prototype/converter/src/utils/path_fmt.rs @@ -0,0 +1,123 @@ +use std::path::{Path, PathBuf}; + +/// Normalize an input path string into a canonical, platform‑agnostic form. +/// +/// This function removes ANSI escape sequences, unifies separators to `/`, +/// collapses duplicate slashes, strips unfriendly characters (`*`, `?`, `"`, `<`, `>`, `|`), +/// resolves simple `..` components, and preserves a trailing slash when present. +/// +/// See examples below for the exact normalization behavior. +/// +/// # Examples +/// +/// ``` +/// # use string_proc::format_path::format_path_str; +/// use std::io::Error; +/// +/// # fn main() -> Result<(), Error> { +/// assert_eq!(format_path_str("C:\\Users\\\\test")?, "C:/Users/test"); +/// assert_eq!( +/// format_path_str("/path/with/*unfriendly?chars")?, +/// "/path/with/unfriendlychars" +/// ); +/// assert_eq!(format_path_str("\x1b[31m/path\x1b[0m")?, "/path"); +/// assert_eq!(format_path_str("/home/user/dir/")?, "/home/user/dir/"); +/// assert_eq!( +/// format_path_str("/home/user/file.txt")?, +/// "/home/user/file.txt" +/// ); +/// assert_eq!( +/// format_path_str("/home/my_user/DOCS/JVCS_TEST/Workspace/../Vault/")?, +/// "/home/my_user/DOCS/JVCS_TEST/Vault/" +/// ); +/// assert_eq!(format_path_str("./home/file.txt")?, "home/file.txt"); +/// assert_eq!(format_path_str("./home/path/")?, "home/path/"); +/// assert_eq!(format_path_str("./")?, ""); +/// # Ok(()) +/// # } +/// ``` +pub fn format_path_str(path: impl Into<String>) -> Result<String, std::io::Error> { + let path_str = path.into(); + let ends_with_slash = path_str.ends_with('/'); + + // ANSI Strip + let cleaned = strip_ansi_escapes::strip(&path_str); + let path_without_ansi = String::from_utf8(cleaned) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let path_with_forward_slash = path_without_ansi.replace('\\', "/"); + let mut result = String::new(); + let mut prev_char = '\0'; + + for c in path_with_forward_slash.chars() { + if c == '/' && prev_char == '/' { + continue; + } + result.push(c); + prev_char = c; + } + + let unfriendly_chars = ['*', '?', '"', '<', '>', '|']; + result = result + .chars() + .filter(|c| !unfriendly_chars.contains(c)) + .collect(); + + // Handle ".." path components + let path_buf = PathBuf::from(&result); + let normalized_path = normalize_path(&path_buf); + result = normalized_path.to_string_lossy().replace('\\', "/"); + + // Restore trailing slash if original path had one + if ends_with_slash && !result.ends_with('/') { + result.push('/'); + } + + // Special case: when result is only "./", return "" + if result == "./" { + return Ok(String::new()); + } + + Ok(result) +} + +/// Normalize path by resolving ".." components without requiring file system access +fn normalize_path(path: &Path) -> PathBuf { + let mut components = Vec::new(); + + for component in path.components() { + match component { + std::path::Component::ParentDir => { + if !components.is_empty() { + components.pop(); + } + } + std::path::Component::CurDir => { + // Skip current directory components + } + _ => { + components.push(component); + } + } + } + + if components.is_empty() { + PathBuf::from(".") + } else { + components.iter().collect() + } +} + +/// Format a [`PathBuf`] into its canonical string form and convert it back. +/// +/// This is a convenience wrapper around [`format_path_str`], preserving +/// the semantics of [`PathBuf`] while applying the same normalization rules: +/// - normalize separators to `/` +/// - remove duplicated separators +/// - strip ANSI escape sequences +/// - remove unfriendly characters (`*`, `?`, etc.) +/// - resolve simple `..` segments +pub fn format_path(path: impl Into<PathBuf>) -> Result<PathBuf, std::io::Error> { + let path_str = format_path_str(path.into().display().to_string())?; + Ok(PathBuf::from(path_str)) +} diff --git a/_prototype/converter/usage.txt b/_prototype/converter/usage.txt new file mode 100644 index 0000000..e03897e --- /dev/null +++ b/_prototype/converter/usage.txt @@ -0,0 +1,6 @@ +mdialogc -i <FILE> -o <FILE> + -i --input <FILE> Input file (Markdown / MarkDialog source) + -o, --output <FILE> Output file (Generated IR / result) + + -h, --help Show this content + -v, --version Show version diff --git a/_prototype/converter/version.txt b/_prototype/converter/version.txt new file mode 100644 index 0000000..032f755 --- /dev/null +++ b/_prototype/converter/version.txt @@ -0,0 +1,2 @@ +mdialogc (0.1.0) +Mark Dialog Converter - Copyright © 2026 Weicao-CatilGrass diff --git a/_prototype/player/Cargo.toml b/_prototype/player/Cargo.toml new file mode 100644 index 0000000..69108fd --- /dev/null +++ b/_prototype/player/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "markdialog_player" +edition = "2024" +version.workspace = true + +[dependencies] diff --git a/_prototype/player/src/lib.rs b/_prototype/player/src/lib.rs new file mode 100644 index 0000000..4723252 --- /dev/null +++ b/_prototype/player/src/lib.rs @@ -0,0 +1,3 @@ +pub fn player_main() { + println!("Hello Markdialog Player") +} diff --git a/_prototype/src/lib.rs b/_prototype/src/lib.rs new file mode 100644 index 0000000..f74f944 --- /dev/null +++ b/_prototype/src/lib.rs @@ -0,0 +1,572 @@ +use proc_macro::TokenStream; +use sha2::Digest; +use std::fs; + +use syn::parse::{Parse, ParseStream, Result}; +use syn::{LitInt, LitStr, parse_macro_input}; + +struct StepInput { + s: String, + n: i64, +} + +impl Parse for StepInput { + fn parse(input: ParseStream) -> Result<Self> { + let s = input.parse::<LitStr>()?.value(); + let n = if input.is_empty() { + 0 + } else { + input.parse::<syn::Token![,]>()?; + let lit = input.parse::<LitInt>()?; + lit.base10_parse::<i64>()? + }; + Ok(StepInput { s, n }) + } +} + +struct MarkDialogInput { + mod_name: syn::Ident, + file_path: String, +} + +impl Parse for MarkDialogInput { + fn parse(input: ParseStream) -> Result<Self> { + let mod_name = input.parse::<syn::Ident>()?; + input.parse::<syn::Token![=]>()?; + let file_path_lit = input.parse::<LitStr>()?; + let file_path = file_path_lit.value(); + + Ok(MarkDialogInput { + mod_name, + file_path, + }) + } +} + +/// Generates a StepId enum based on a string +/// Will select the StepId imported or declared in the current module +/// +/// ``` +/// # use res_gen_macros::step; +/// #[allow(non_camel_case_types)] +/// #[derive(Debug, PartialEq)] +/// enum StepId { +/// R_09B538D1_0, // Begin +/// } +/// +/// assert_eq!(step!("Begin"), StepId::R_09B538D1_0); +/// ``` +#[proc_macro] +pub fn step(input: TokenStream) -> TokenStream { + let StepInput { s, n } = parse_macro_input!(input as StepInput); + + let hash = sha2::Sha256::digest(s.as_bytes()); + let hex = format!("{:x}", hash); + let short = &hex[..8]; + + let ident_str = format!("R_{}_{}", short.to_uppercase(), n); + let ident = syn::Ident::new(&ident_str, proc_macro2::Span::call_site()); + + let expanded = quote::quote! { + StepId::#ident + }; + + expanded.into() +} + +/// Generates a dialog module from a .dialog file +/// +/// ```ignore +/// use markdialog::generate::{markdialog, step}; +/// +/// // Define here +/// markdialog!(my = "Chapter1.dialog"); +/// +/// fn main() { +/// let my_step = my::get_step(step!("Begin")).unwrap(); +/// let sentence = my_step.sentences[0]; +/// +/// // Print my sentences +/// println!( +/// "{} said : \"{}\"", +/// sentence.character.unwrap_or_default(), +/// sentence +/// .content_tokens +/// .iter() +/// .map(|token| match token { +/// my::Token::Text(t) => t, +/// my::Token::BoldText(t) => t, +/// my::Token::ItalicText(t) => t, +/// my::Token::BoldItalicText(t) => t, +/// _ => "", +/// } +/// .to_string()) +/// .collect::<Vec<String>>() +/// .join("") +/// ) +/// } +/// ``` +#[proc_macro] +pub fn markdialog(input: TokenStream) -> TokenStream { + let MarkDialogInput { + mod_name, + file_path, + } = parse_macro_input!(input as MarkDialogInput); + + // Read file content + let content = match fs::read_to_string(&file_path) { + Ok(content) => content, + Err(e) => { + return syn::Error::new( + proc_macro2::Span::call_site(), + format!("Failed to read dialog file '{}': {}", file_path, e), + ) + .to_compile_error() + .into(); + } + }; + + // Parse dialog file + let parsed_dialog = match parse_dialog_file(&content) { + Ok(parsed) => parsed, + Err(e) => { + return syn::Error::new( + proc_macro2::Span::call_site(), + format!("Failed to parse dialog file: {}", e), + ) + .to_compile_error() + .into(); + } + }; + + // Generate code + let expanded = generate_dialog_module(&mod_name, &parsed_dialog); + + expanded.into() +} + +fn parse_dialog_file(content: &str) -> std::result::Result<DialogFile, String> { + let mut steps = Vec::new(); + let mut current_step_id: Option<String> = None; + let mut current_sentences: Vec<SentenceData> = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + if line.is_empty() { + continue; + } + + // Check if it's a Step definition line + if line.starts_with("@@@@@@@@@@") { + // Save previous Step + if let Some(step_id) = current_step_id.take() { + steps.push(StepData { + id: step_id, + sentences: std::mem::take(&mut current_sentences), + }); + } + + // Extract new Step ID + let step_id = line["@@@@@@@@@@".len()..].trim().to_string(); + current_step_id = Some(step_id); + } else if let Some(_step_id) = ¤t_step_id { + // Parse Sentence line + match parse_sentence_line(line) { + Ok(sentence) => current_sentences.push(sentence), + Err(e) => return Err(format!("Failed to parse sentence line '{}': {}", line, e)), + } + } + } + + // Save the last Step + if let Some(step_id) = current_step_id { + steps.push(StepData { + id: step_id, + sentences: current_sentences, + }); + } + + Ok(DialogFile { steps }) +} + +fn parse_sentence_line(line: &str) -> std::result::Result<SentenceData, String> { + // Split character part and content part + let parts: Vec<&str> = line.split("->").collect(); + if parts.len() != 2 { + return Err(format!("Invalid sentence line format: {}", line)); + } + + let left_part = parts[0].trim(); + let right_part = parts[1].trim(); + + // Parse character part + let (character, silence_switch) = parse_character_part(left_part)?; + + // Parse content blocks + let content_tokens = parse_content_blocks(left_part)?; + + // Parse jump block + let next_step = parse_next_step(right_part)?; + + Ok(SentenceData { + character, + content_tokens, + next_step, + silence_switch, + }) +} + +fn parse_character_part(line: &str) -> std::result::Result<(Option<String>, bool), String> { + // Find the position of the first ']' + let end_bracket = line + .find(']') + .ok_or_else(|| "No closing bracket found for character".to_string())?; + let character_part = &line[..=end_bracket]; + + if character_part == "[]" { + return Ok((None, false)); + } + + if character_part == "[**]" { + return Ok((None, true)); + } + + // Check if it's wrapped with * + let has_stars = character_part.starts_with("[*") && character_part.ends_with("*]"); + + let start = if has_stars { 2 } else { 1 }; + let end = character_part.len() - (if has_stars { 2 } else { 1 }); + + let character_content = &character_part[start..end]; + + // Handle Unicode escape sequences + let decoded = decode_unicode_escapes(character_content)?; + + Ok((Some(decoded), has_stars)) +} + +fn parse_content_blocks(line: &str) -> std::result::Result<Vec<TokenData>, String> { + let mut tokens = Vec::new(); + let mut current_pos; + + // Skip the character part + let first_bracket = line + .find(']') + .ok_or_else(|| "No closing bracket found".to_string())?; + current_pos = first_bracket + 1; + + while current_pos < line.len() { + // Find the next '[' + if let Some(start) = line[current_pos..].find('[') { + let start_pos = current_pos + start; + + // Find the matching ']' + let mut bracket_count = 0; + let mut end_pos = None; + + for (i, ch) in line[start_pos..].char_indices() { + match ch { + '[' => bracket_count += 1, + ']' => { + bracket_count -= 1; + if bracket_count == 0 { + end_pos = Some(start_pos + i); + break; + } + } + _ => {} + } + } + + if let Some(end) = end_pos { + let block = &line[start_pos..=end]; + + // Parse content block + if let Ok(token) = parse_content_block(block) { + tokens.push(token); + } + + current_pos = end + 1; + } else { + break; + } + } else { + break; + } + } + + Ok(tokens) +} + +fn parse_content_block(block: &str) -> std::result::Result<TokenData, String> { + // Format: [label:[content]] + let inner_start = block + .find(':') + .ok_or_else(|| "No colon in content block".to_string())?; + let label = &block[1..inner_start]; + + // Find the start and end of content + let content_start = inner_start + 2; // Skip ':[' + let content_end = block.len() - 2; // Skip the trailing ']]' + + if content_start >= content_end { + return Err("Empty content in block".to_string()); + } + + let content = &block[content_start..content_end]; + + // Handle Unicode escape sequences + let decoded_content = decode_unicode_escapes(content)?; + + // Remove backticks if present + let trimmed_content = decoded_content.trim_matches('`'); + + match label { + "text" => Ok(TokenData::Text(trimmed_content.to_string())), + "bold" => Ok(TokenData::BoldText(trimmed_content.to_string())), + "italic" => Ok(TokenData::ItalicText(trimmed_content.to_string())), + "bold_italic" => Ok(TokenData::BoldItalicText(trimmed_content.to_string())), + "code" => Ok(TokenData::Code(trimmed_content.to_string())), + _ => Err(format!("Unknown label: {}", label)), + } +} + +fn parse_next_step(block: &str) -> std::result::Result<Option<String>, String> { + if block == "[]" { + return Ok(None); + } + + if !block.starts_with("[#") || !block.ends_with(']') { + return Err(format!("Invalid next step format: {}", block)); + } + + let step_id = &block[2..block.len() - 1]; + Ok(Some(step_id.to_string())) +} + +fn decode_unicode_escapes(input: &str) -> std::result::Result<String, String> { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(&next) = chars.peek() { + if next == 'u' { + chars.next(); // skip 'u' + + // read 4 hex digits + let mut hex_str = String::new(); + for _ in 0..4 { + if let Some(&hex_char) = chars.peek() { + if hex_char.is_ascii_hexdigit() { + hex_str.push(hex_char); + chars.next(); + } else { + return Err("Invalid Unicode escape sequence".to_string()); + } + } else { + return Err("Incomplete Unicode escape sequence".to_string()); + } + } + + // parse hex number + if let Ok(code_point) = u32::from_str_radix(&hex_str, 16) { + if let Some(unicode_char) = char::from_u32(code_point) { + result.push(unicode_char); + } else { + return Err(format!("Invalid Unicode code point: {}", code_point)); + } + } else { + return Err(format!("Invalid hex number: {}", hex_str)); + } + } else { + result.push(ch); + result.push(next); + chars.next(); + } + } else { + result.push(ch); + } + } else { + result.push(ch); + } + } + + Ok(result) +} + +fn generate_dialog_module(mod_name: &syn::Ident, dialog: &DialogFile) -> proc_macro2::TokenStream { + // Generate StepId enum + let step_id_variants: Vec<_> = dialog + .steps + .iter() + .map(|step| { + let id_upper = step.id.to_uppercase().replace('-', "_"); + let variant_name = + syn::Ident::new(&format!("R_{}", id_upper), proc_macro2::Span::call_site()); + quote::quote! { + #variant_name, + } + }) + .collect(); + + let step_id_variants_formatted = if step_id_variants.is_empty() { + quote::quote! {} + } else { + quote::quote! { + #(#step_id_variants)* + } + }; + + // Generate get_step function + let match_arms: Vec<_> = dialog + .steps + .iter() + .map(|step| { + let id_upper = step.id.to_uppercase().replace('-', "_"); + let variant_name = + syn::Ident::new(&format!("R_{}", id_upper), proc_macro2::Span::call_site()); + + // Generate sentences + let sentences: Vec<_> = step + .sentences + .iter() + .map(|sentence| { + // Generate Token array + let tokens: Vec<_> = sentence + .content_tokens + .iter() + .map(|token| match token { + TokenData::Text(text) => { + quote::quote! { &Token::Text(#text) } + } + TokenData::BoldText(text) => { + quote::quote! { &Token::BoldText(#text) } + } + TokenData::ItalicText(text) => { + quote::quote! { &Token::ItalicText(#text) } + } + TokenData::BoldItalicText(text) => { + quote::quote! { &Token::BoldItalicText(#text) } + } + TokenData::Code(text) => { + quote::quote! { &Token::Code(#text) } + } + }) + .collect(); + + let character_expr = if let Some(ref char_name) = sentence.character { + quote::quote! { Some(#char_name) } + } else { + quote::quote! { None } + }; + + let next_step_expr = if let Some(ref next_step_id) = sentence.next_step { + let next_id_upper = next_step_id.to_uppercase().replace('-', "_"); + let next_variant_name = syn::Ident::new( + &format!("R_{}", next_id_upper), + proc_macro2::Span::call_site(), + ); + quote::quote! { Some(StepId::#next_variant_name) } + } else { + quote::quote! { None } + }; + + let silence_switch = sentence.silence_switch; + quote::quote! { + &Sentence { + character: #character_expr, + content_tokens: &[#(#tokens),*], + next_step: #next_step_expr, + silence_switch: #silence_switch, + } + } + }) + .collect(); + + let sentences_formatted = if sentences.is_empty() { + quote::quote! { &[] } + } else { + quote::quote! { &[#(#sentences),*] } + }; + + quote::quote! { + StepId::#variant_name => Some(Step { + sentences: #sentences_formatted, + }), + } + }) + .collect(); + + quote::quote! { + pub use #mod_name::StepId; + pub mod #mod_name { + #[allow(non_camel_case_types)] + pub enum StepId { + #step_id_variants_formatted + } + + pub struct Step<'a> { + pub sentences: &'a [&'a Sentence<'a>], + } + + pub struct Sentence<'a> { + pub character: Option<&'a str>, + pub content_tokens: &'a [&'a Token<'a>], + pub next_step: Option<StepId>, + pub silence_switch: bool, + } + + pub enum Token<'a> { + Text(&'a str), + BoldText(&'a str), + ItalicText(&'a str), + BoldItalicText(&'a str), + Code(&'a str), + } + + impl<'a> std::fmt::Display for Token<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Token::Text(text) => write!(f, "{}", text), + Token::BoldText(text) => write!(f, "**{}**", text), + Token::ItalicText(text) => write!(f, "*{}*", text), + Token::BoldItalicText(text) => write!(f, "***{}***", text), + Token::Code(text) => write!(f, "`{}`", text), + } + } + } + + pub fn get_step(id: StepId) -> Option<Step<'static>> { + match id { + #(#match_arms)* + } + } + } + } +} + +struct DialogFile { + steps: Vec<StepData>, +} + +struct StepData { + id: String, + sentences: Vec<SentenceData>, +} + +struct SentenceData { + character: Option<String>, + content_tokens: Vec<TokenData>, + next_step: Option<String>, + silence_switch: bool, +} + +enum TokenData { + Text(String), + BoldText(String), + ItalicText(String), + BoldItalicText(String), + Code(String), +} |
