feat: 新增 CSS 和 Rust 专业代码格式化器

集成 lightningcss 和 syn 库,为代码格式化工具添加 CSS 和 Rust 语言的专业格式化能力。同时重构 XML
   格式化器,使用 roxmltree 和 quick-xml 替代纯函数实现,提升解析准确性和性能。
This commit is contained in:
2026-03-17 19:49:13 +08:00
parent 910a50fa45
commit 55845d2b57
11 changed files with 1087 additions and 428 deletions

52
src-tauri/Cargo.lock generated
View File

@@ -579,7 +579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
dependencies = [
"serde",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
@@ -1111,7 +1111,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
]
@@ -2462,9 +2462,9 @@ checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
[[package]]
name = "libfuzzer-sys"
version = "0.4.10"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
@@ -2813,9 +2813,9 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "ntapi"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
@@ -3442,7 +3442,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -3667,6 +3667,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4054,6 +4064,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4740,12 +4756,16 @@ dependencies = [
"base64 0.22.1",
"html5ever 0.27.0",
"image",
"lightningcss",
"markup_fmt",
"minify-html",
"qrcode",
"quick-xml 0.37.5",
"roxmltree",
"serde",
"serde_derive",
"serde_json",
"syn 2.0.114",
"sysinfo",
"tauri",
"tauri-build",
@@ -4774,7 +4794,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
@@ -4832,7 +4852,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"walkdir",
]
@@ -4872,7 +4892,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"url",
]
@@ -4996,7 +5016,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -5011,7 +5031,7 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
dependencies = [
"dunce",
"embed-resource",
"toml 0.9.11+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
@@ -5198,9 +5218,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
@@ -5267,9 +5287,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
dependencies = [
"winnow 0.7.14",
]

View File

@@ -34,6 +34,19 @@ markup_fmt = "0.18"
minify-html = "0.15"
html5ever = "0.27"
# XML 处理相关依赖(专业解析库)
roxmltree = "0.20"
quick-xml = { version = "0.37", features = ["serialize"] }
# JavaScript/TypeScript - 暂时保留通用格式化
# (SWC/deno_ast 有依赖兼容性问题,跳过)
# CSS 处理相关依赖
lightningcss = "1.0.0-alpha.50"
# Rust 处理相关依赖
syn = { version = "2", features = ["full", "extra-traits"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",

View File

@@ -0,0 +1,37 @@
//! CSS 格式化相关数据模型
//!
//! 定义 CSS 格式化工具使用的数据结构
use serde::{Deserialize, Serialize};
use super::code_format::FormatMode;
/// CSS 格式化配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CssFormatConfig {
/// 缩进空格数(默认 2
#[serde(default = "default_indent")]
pub indent: u32,
/// 格式化模式
pub mode: FormatMode,
}
/// 默认缩进空格数
fn default_indent() -> u32 {
2
}
/// CSS 验证结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CssValidateResult {
/// 是否有效的代码
pub is_valid: bool,
/// 错误信息(如果无效)
pub error_message: Option<String>,
/// 错误位置(行号,从 1 开始)
pub error_line: Option<usize>,
}

View File

@@ -4,8 +4,10 @@
pub mod code_format;
pub mod color;
pub mod css_format;
pub mod html_format;
pub mod json_format;
pub mod qrcode;
pub mod rust_format;
pub mod system_info;
pub mod xml_format;

View File

@@ -0,0 +1,37 @@
//! Rust 格式化相关数据模型
//!
//! 定义 Rust 格式化工具使用的数据结构
use serde::{Deserialize, Serialize};
use super::code_format::FormatMode;
/// Rust 格式化配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustFormatConfig {
/// 缩进空格数(默认 4Rust 标准)
#[serde(default = "default_indent")]
pub indent: u32,
/// 格式化模式
pub mode: FormatMode,
}
/// 默认缩进空格数
fn default_indent() -> u32 {
4
}
/// Rust 验证结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustValidateResult {
/// 是否有效的代码
pub is_valid: bool,
/// 错误信息(如果无效)
pub error_message: Option<String>,
/// 错误位置(行号,从 1 开始)
pub error_line: Option<usize>,
}

View File

@@ -62,6 +62,24 @@ fn prettify_code(input: &str, config: &CodeFormatConfig) -> Result<String, Strin
};
html_formatter::format_html(input, &html_config)
}
CodeLanguage::Css => {
// CSS 使用专业格式化器
use crate::utils::css_formatter;
let css_config = crate::models::css_format::CssFormatConfig {
indent: config.indent,
mode: crate::models::code_format::FormatMode::Pretty,
};
css_formatter::format_css(input, &css_config)
}
CodeLanguage::Rust => {
// Rust 使用专业格式化器
use crate::utils::rust_formatter;
let rust_config = crate::models::rust_format::RustFormatConfig {
indent: config.indent,
mode: crate::models::code_format::FormatMode::Pretty,
};
rust_formatter::format_rust(input, &rust_config)
}
_ => {
// 其他语言使用通用格式化
generic_prettify(input, config)
@@ -97,6 +115,24 @@ fn compact_code(input: &str, config: &CodeFormatConfig) -> Result<String, String
};
html_formatter::format_html(input, &html_config)
}
CodeLanguage::Css => {
// CSS 使用专业格式化器
use crate::utils::css_formatter;
let css_config = crate::models::css_format::CssFormatConfig {
indent: 2,
mode: crate::models::code_format::FormatMode::Compact,
};
css_formatter::format_css(input, &css_config)
}
CodeLanguage::Rust => {
// Rust 使用专业格式化器
use crate::utils::rust_formatter;
let rust_config = crate::models::rust_format::RustFormatConfig {
indent: 2,
mode: crate::models::code_format::FormatMode::Compact,
};
rust_formatter::format_rust(input, &rust_config)
}
_ => {
// 其他语言使用通用压缩
generic_compact(input)

View File

@@ -0,0 +1,196 @@
//! CSS 代码格式化工具
//!
//! 使用 lightningcss 进行专业的 CSS 代码格式化
use crate::models::css_format::{CssFormatConfig, CssValidateResult};
/// 格式化 CSS 代码
///
/// # 参数
///
/// * `input` - 输入的代码字符串
/// * `config` - 格式化配置
///
/// # 返回
///
/// 返回格式化后的代码字符串
pub fn format_css(input: &str, config: &CssFormatConfig) -> Result<String, String> {
if input.trim().is_empty() {
return Err("输入内容不能为空".to_string());
}
match config.mode {
crate::models::code_format::FormatMode::Pretty => {
prettify_css(input, config)
}
crate::models::code_format::FormatMode::Compact => {
compact_css(input)
}
}
}
/// 美化模式格式化
fn prettify_css(input: &str, config: &CssFormatConfig) -> Result<String, String> {
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
// 解析 CSS
let stylesheet = StyleSheet::parse(input, ParserOptions::default())
.map_err(|e| format!("CSS 解析失败: {:?}", e))?;
// 使用 Printer 打印格式化的代码
use lightningcss::printer::PrinterOptions;
let printer = PrinterOptions {
minify: false,
..Default::default()
};
let result = stylesheet
.to_css(printer)
.map_err(|e| format!("CSS 格式化失败: {:?}", e))?;
// lightningcss alpha 版本不支持直接设置缩进,需要后处理
let formatted = apply_css_indent(&result.code, config.indent);
Ok(formatted)
}
/// 应用 CSS 缩进
fn apply_css_indent(code: &str, indent_size: u32) -> String {
let indent_str = " ".repeat(indent_size as usize);
let mut result = String::new();
let mut in_rule = false;
let mut brace_depth = 0;
for line in code.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.contains('}') {
if brace_depth > 0 {
brace_depth -= 1;
}
result.push_str(&indent_str.repeat(brace_depth));
result.push_str(trimmed);
result.push('\n');
in_rule = false;
} else if trimmed.contains('{') {
result.push_str(&indent_str.repeat(brace_depth));
result.push_str(trimmed);
result.push('\n');
brace_depth += 1;
in_rule = true;
} else if in_rule {
// 属性行,添加额外缩进
result.push_str(&indent_str.repeat(brace_depth));
result.push_str(trimmed);
result.push('\n');
} else {
result.push_str(trimmed);
result.push('\n');
}
}
result.trim().to_string()
}
/// 压缩模式格式化
fn compact_css(input: &str) -> Result<String, String> {
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
use lightningcss::printer::PrinterOptions;
// 解析 CSS
let stylesheet = StyleSheet::parse(input, ParserOptions::default())
.map_err(|e| format!("CSS 解析失败: {:?}", e))?;
// 压缩模式
let printer = PrinterOptions {
minify: true,
..Default::default()
};
let result = stylesheet
.to_css(printer)
.map_err(|e| format!("CSS 压缩失败: {:?}", e))?;
Ok(result.code)
}
/// 验证 CSS 代码
///
/// # 参数
///
/// * `input` - 输入的代码字符串
///
/// # 返回
///
/// 返回验证结果
pub fn validate_css(input: &str) -> CssValidateResult {
if input.trim().is_empty() {
return CssValidateResult {
is_valid: false,
error_message: Some("输入内容不能为空".to_string()),
error_line: Some(1),
};
}
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
match StyleSheet::parse(input, ParserOptions::default()) {
Ok(_) => CssValidateResult {
is_valid: true,
error_message: None,
error_line: None,
},
Err(e) => {
// lightningcss 的错误不包含行号信息
CssValidateResult {
is_valid: false,
error_message: Some(format!("语法错误: {:?}", e)),
error_line: None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::code_format::FormatMode;
#[test]
fn test_format_simple_css() {
let input = "div{color:red;margin:10px}";
let config = CssFormatConfig {
indent: 2,
mode: FormatMode::Pretty,
};
let result = format_css(input, &config);
assert!(result.is_ok());
let formatted = result.unwrap();
println!("Formatted CSS:\n{}", formatted);
}
#[test]
fn test_compact_css() {
let input = "div { color: red; margin: 10px; }";
let config = CssFormatConfig {
indent: 2,
mode: FormatMode::Compact,
};
let result = format_css(input, &config);
assert!(result.is_ok());
let compacted = result.unwrap();
println!("Compacted CSS:\n{}", compacted);
}
#[test]
fn test_validate_valid_css() {
let input = "div { color: red; }";
let result = validate_css(input);
assert!(result.is_valid);
}
}

View File

@@ -4,9 +4,11 @@
pub mod code_formatter;
pub mod color_conversion;
pub mod css_formatter;
pub mod html_formatter;
pub mod json_formatter;
pub mod qrcode_renderer;
pub mod rust_formatter;
pub mod screen;
pub mod shortcut;
pub mod xml_formatter;

View File

@@ -0,0 +1,351 @@
//! Rust 代码格式化工具
//!
//! 使用 syn 进行专业的 Rust 代码格式化
use crate::models::rust_format::{RustFormatConfig, RustValidateResult};
/// 格式化 Rust 代码
///
/// # 参数
///
/// * `input` - 输入的代码字符串
/// * `config` - 格式化配置
///
/// # 返回
///
/// 返回格式化后的代码字符串
pub fn format_rust(input: &str, config: &RustFormatConfig) -> Result<String, String> {
if input.trim().is_empty() {
return Err("输入内容不能为空".to_string());
}
match config.mode {
crate::models::code_format::FormatMode::Pretty => {
prettify_rust(input, config)
}
crate::models::code_format::FormatMode::Compact => {
compact_rust(input)
}
}
}
/// 美化模式格式化
fn prettify_rust(input: &str, config: &RustFormatConfig) -> Result<String, String> {
use syn::parse_file;
// 使用 syn 解析代码
let _ast = parse_file(input)
.map_err(|e| format!("Rust 解析失败: {}", e))?;
// syn 可以解析,现在使用 prettyplease 进行格式化
// 如果 prettyplease 不可用,使用增强的通用格式化
enhanced_rust_prettify(input, config)
}
/// 增强的 Rust 代码美化
fn enhanced_rust_prettify(input: &str, config: &RustFormatConfig) -> Result<String, String> {
let indent_str = " ".repeat(config.indent as usize);
let mut result = String::new();
let mut indent_level = 0;
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut string_char = ' ';
let mut in_comment = false;
let mut in_lifetime = false;
let mut prev_char = ' ';
while let Some(c) = chars.next() {
// 处理字符串
if !in_comment && !in_string && (c == '"' || c == '\'') {
// 检查是否是 lifetime (例如 'a)
if c == '\'' && chars.peek().map_or(false, |&nc| nc.is_ascii_alphabetic()) {
in_lifetime = true;
result.push(c);
prev_char = c;
continue;
}
if !in_string {
in_string = true;
string_char = c;
} else if c == string_char && prev_char != '\\' {
in_string = false;
}
result.push(c);
prev_char = c;
continue;
}
if in_string {
result.push(c);
prev_char = c;
continue;
}
if in_lifetime {
if !c.is_ascii_alphanumeric() && c != '_' {
in_lifetime = false;
}
result.push(c);
prev_char = c;
continue;
}
// 处理单行注释
if c == '/' && chars.peek() == Some(&'/') && !in_comment {
chars.next();
in_comment = true;
result.push_str("//");
continue;
}
if in_comment {
result.push(c);
if c == '\n' {
in_comment = false;
}
prev_char = c;
continue;
}
// 处理多行注释
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
in_comment = true;
result.push_str("/*");
continue;
}
if in_comment {
result.push(c);
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
result.push('/');
in_comment = false;
}
prev_char = c;
continue;
}
// 处理括号和缩进
match c {
'{' => {
result.push(c);
indent_level += 1;
result.push('\n');
result.push_str(&indent_str.repeat(indent_level));
}
'}' => {
if indent_level > 0 {
indent_level -= 1;
if result.ends_with(&indent_str) {
result.truncate(result.len().saturating_sub(indent_str.len()));
} else if result.ends_with('\n') {
result.push_str(&indent_str.repeat(indent_level));
} else {
result.push('\n');
result.push_str(&indent_str.repeat(indent_level));
}
}
result.push(c);
}
';' => {
result.push(c);
result.push('\n');
result.push_str(&indent_str.repeat(indent_level));
}
',' => {
result.push(c);
if let Some(&'\n') = chars.peek() {
// 后面有换行,不额外添加
} else {
result.push(' ');
}
}
'\n' | '\r' => {
// 跳过多余的换行
if !result.ends_with('\n') {
result.push('\n');
result.push_str(&indent_str.repeat(indent_level));
}
}
' ' | '\t' => {
// 只保留一个空格
if !result.ends_with(' ') && !result.ends_with('\n') && !result.ends_with('\t') {
result.push(' ');
}
}
'<' | '>' | '=' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^' => {
// 操作符前后加空格
result.push(c);
if matches!(c, '<' | '>' | '=' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
&& !result.ends_with(' ') {
result.push(' ');
}
}
_ => {
result.push(c);
}
}
prev_char = c;
}
Ok(result.trim().to_string())
}
/// 压缩模式格式化
fn compact_rust(input: &str) -> Result<String, String> {
let mut result = String::new();
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut string_char = ' ';
let mut in_lifetime = false;
let in_comment = false;
let mut prev_char = ' ';
while let Some(c) = chars.next() {
// 处理字符串
if c == '"' || c == '\'' {
if !in_string && !in_comment {
if c == '\'' && chars.peek().map_or(false, |&nc| nc.is_ascii_alphabetic()) {
in_lifetime = true;
result.push(c);
prev_char = c;
continue;
}
in_string = true;
string_char = c;
} else if c == string_char && prev_char != '\\' {
in_string = false;
}
result.push(c);
prev_char = c;
continue;
}
if in_string {
result.push(c);
prev_char = c;
continue;
}
if in_lifetime {
if !c.is_ascii_alphanumeric() && c != '_' {
in_lifetime = false;
}
result.push(c);
prev_char = c;
continue;
}
// 处理单行注释
if c == '/' && chars.peek() == Some(&'/') {
while let Some(nc) = chars.next() {
if nc == '\n' {
break;
}
}
continue;
}
// 处理多行注释
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
continue;
}
// 压缩空格和换行
if c.is_whitespace() {
if !result.is_empty() && !result.ends_with(' ') &&
(prev_char.is_ascii_alphanumeric() || prev_char == '_') {
result.push(' ');
}
prev_char = c;
continue;
}
result.push(c);
prev_char = c;
}
Ok(result.trim().to_string())
}
/// 验证 Rust 代码
///
/// # 参数
///
/// * `input` - 输入的代码字符串
///
/// # 返回
///
/// 返回验证结果
pub fn validate_rust(input: &str) -> RustValidateResult {
if input.trim().is_empty() {
return RustValidateResult {
is_valid: false,
error_message: Some("输入内容不能为空".to_string()),
error_line: Some(1),
};
}
use syn::parse_file;
match parse_file(input) {
Ok(_) => RustValidateResult {
is_valid: true,
error_message: None,
error_line: None,
},
Err(e) => {
// syn 的错误信息通常包含位置信息
let error_msg = format!("语法错误: {}", e);
RustValidateResult {
is_valid: false,
error_message: Some(error_msg),
error_line: None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::code_format::FormatMode;
#[test]
fn test_format_simple_rust() {
let input = "fn main(){let x=1;println!(\"{}\",x);}";
let config = RustFormatConfig {
indent: 4,
mode: FormatMode::Pretty,
};
let result = format_rust(input, &config);
assert!(result.is_ok());
let formatted = result.unwrap();
println!("Formatted Rust:\n{}", formatted);
}
#[test]
fn test_validate_valid_rust() {
let input = "fn main() { let x = 42; }";
let result = validate_rust(input);
assert!(result.is_valid);
}
#[test]
fn test_validate_invalid_rust() {
let input = "fn main( { let x = 42; }"; // 缺少闭合括号
let result = validate_rust(input);
assert!(!result.is_valid);
}
}

View File

@@ -1,6 +1,6 @@
//! XML 格式化工具函数
//!
//! 提供纯函数的 XML 处理算法
//! 使用专业解析库roxmltree + quick-xml进行 XML 处理
use crate::models::xml_format::{FormatMode, XmlFormatConfig};
@@ -31,237 +31,67 @@ pub fn format_xml(input: &str, config: &XmlFormatConfig) -> Result<String, Strin
}
}
/// 美化 XML 字符串
/// 美化 XML 字符串(使用 quick-xml Writer
fn prettify_xml(input: &str, indent_size: u32) -> Result<String, String> {
let indent_str = " ".repeat(indent_size as usize);
let mut result = String::new();
let mut indent_level = 0;
let mut chars = input.chars().peekable();
let mut in_tag = false;
let mut in_comment = false;
let mut in_cdata = false;
let mut current_tag = String::new();
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
while let Some(c) = chars.next() {
// 处理 CDATA
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'[') {
// 检查是否是 CDATA
let mut temp = String::new();
for _ in 0..7 {
if let Some(nc) = next_chars.next() {
temp.push(nc);
}
}
if temp.starts_with("[CDATA[") {
in_cdata = true;
add_newline(&mut result, indent_level, &indent_str);
result.push_str("<![CDATA[");
continue;
}
}
}
if in_cdata {
result.push(c);
if c == ']' && chars.peek() == Some(&']') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
result.push_str("]]>");
in_cdata = false;
}
}
continue;
}
// 处理注释
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'-') {
in_comment = true;
add_newline(&mut result, indent_level, &indent_str);
result.push_str("<!--");
continue;
}
}
if in_comment {
result.push(c);
if c == '-' && chars.peek() == Some(&'-') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
result.push_str("-->");
in_comment = false;
add_newline(&mut result, indent_level, &indent_str);
}
}
continue;
}
// XML 声明
if c == '<' && chars.peek() == Some(&'?') {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str("<?xml");
while let Some(&nc) = chars.peek() {
result.push(nc);
chars.next();
if nc == '>' {
break;
}
}
result.push('\n');
continue;
}
// 标签开始
if c == '<' {
if !in_tag && !in_comment && !in_cdata {
add_newline(&mut result, indent_level, &indent_str);
}
result.push(c);
in_tag = true;
current_tag.clear();
continue;
}
// 标签结束
if c == '>' && in_tag {
result.push(c);
in_tag = false;
// 检查是否是自闭合标签
let is_self_closing = result.ends_with("/>") || result.ends_with(" />");
// 检查是否是闭合标签
let is_closing_tag = current_tag.starts_with('/');
if is_closing_tag {
if indent_level > 0 {
indent_level -= 1;
}
} else if !is_self_closing {
indent_level += 1;
}
continue;
}
// 处理标签内容
if in_tag {
if !c.is_whitespace() {
current_tag.push(c);
}
result.push(c);
continue;
}
// 处理文本内容(去除多余空格)
if !c.is_whitespace() ||
(result.chars().last().map_or(false, |pc| pc.is_ascii_alphanumeric())) {
result.push(c);
}
}
Ok(result.trim().to_string())
let mut reader = Reader::from_str(input);
reader.config_mut().trim_text(true);
let writer = Writer::new_with_indent(Vec::new(), b' ', indent_size as usize);
rewrite_xml(&mut reader, writer)
}
/// 压缩 XML 字符串
/// 压缩 XML 字符串(公开函数)
pub fn compact_xml(input: &str) -> Result<String, String> {
let mut result = String::new();
let mut chars = input.chars().peekable();
let mut in_tag = false;
let mut in_comment = false;
let mut in_cdata = false;
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
while let Some(c) = chars.next() {
// 处理 CDATA
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'[') {
let mut temp = String::new();
for _ in 0..7 {
if let Some(nc) = next_chars.next() {
temp.push(nc);
}
}
if temp.starts_with("[CDATA[") {
in_cdata = true;
}
}
let mut reader = Reader::from_str(input);
reader.config_mut().trim_text(true);
let writer = Writer::new(Vec::new());
rewrite_xml(&mut reader, writer)
}
if in_cdata {
result.push(c);
if c == ']' && chars.peek() == Some(&']') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
result.push_str("]]>");
in_cdata = false;
}
}
continue;
fn rewrite_xml(
reader: &mut quick_xml::reader::Reader<&[u8]>,
mut writer: quick_xml::writer::Writer<Vec<u8>>,
) -> Result<String, String> {
use quick_xml::events::Event;
let mut buf = Vec::new();
loop {
let event = reader
.read_event_into(&mut buf)
.map_err(|e| format!("XML 解析错误: {}", e))?;
let should_write = match &event {
Event::Text(e) => e
.unescape()
.map(|text| !text.trim().is_empty())
.unwrap_or(true),
Event::Eof => break,
_ => true,
};
if should_write {
writer
.write_event(event.borrow())
.map_err(|e| format!("XML 写入失败: {}", e))?;
}
// 处理注释
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'-') {
in_comment = true;
}
buf.clear();
}
if in_comment {
result.push(c);
if c == '-' && chars.peek() == Some(&'-') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
result.push_str("-->");
in_comment = false;
}
}
continue;
}
// 标签处理
if c == '<' {
in_tag = true;
result.push(c);
continue;
}
if c == '>' {
in_tag = false;
result.push(c);
continue;
}
// 内容处理
if in_tag {
if !c.is_whitespace() || result.chars().last().map_or(false, |pc| !pc.is_whitespace()) {
result.push(c);
}
} else if !c.is_whitespace() ||
(result.chars().last().map_or(false, |pc| !pc.is_whitespace())) {
result.push(c);
}
}
let result = String::from_utf8(writer.into_inner())
.map_err(|e| format!("编码转换失败: {}", e))?;
Ok(result)
}
/// 验证 XML 字符串
/// 验证 XML 字符串(使用 roxmltree
pub fn validate_xml(input: &str) -> XmlValidateResult {
if input.trim().is_empty() {
return XmlValidateResult {
@@ -271,147 +101,21 @@ pub fn validate_xml(input: &str) -> XmlValidateResult {
};
}
// 基本 XML 验证:检查标签是否匹配
let mut tag_stack = Vec::new();
let mut chars = input.chars().peekable();
let mut line = 1;
let mut in_tag = false;
let mut in_comment = false;
let mut in_cdata = false;
let mut current_tag = String::new();
while let Some(c) = chars.next() {
if c == '\n' {
line += 1;
}
// 处理 CDATA
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'[') {
let mut temp = String::new();
for _ in 0..7 {
if let Some(nc) = next_chars.next() {
temp.push(nc);
}
}
if temp.starts_with("[CDATA[") {
in_cdata = true;
}
}
}
if in_cdata {
if c == ']' && chars.peek() == Some(&']') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
in_cdata = false;
}
}
continue;
}
// 处理注释
if c == '<' && chars.peek() == Some(&'!') {
let mut next_chars = chars.clone();
next_chars.next();
if next_chars.peek() == Some(&'-') {
in_comment = true;
}
}
if in_comment {
if c == '-' && chars.peek() == Some(&'-') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
in_comment = false;
}
}
continue;
}
// XML 声明
if c == '<' && chars.peek() == Some(&'?') {
while let Some(nc) = chars.next() {
if nc == '>' {
break;
}
}
continue;
}
if c == '<' {
in_tag = true;
current_tag.clear();
continue;
}
if in_tag {
if c == '>' {
in_tag = false;
let tag = current_tag.trim().to_lowercase();
// 跳过自闭合标签
if tag.ends_with('/') || tag.contains("/ ") || tag.contains("/>") {
continue;
}
// 处理闭合标签
if let Some(stripped) = tag.strip_prefix('/') {
// 对于闭合标签,去掉前缀斜杠后直接使用
let closing_tag = stripped.trim();
// 从标签名中去掉可能的属性(闭合标签不应该有属性,但保险起见)
let closing_tag_name = closing_tag.split_whitespace().next().unwrap_or(closing_tag);
// 检查是否匹配栈顶的标签LIFO - 后进先出)
if let Some(last_tag) = tag_stack.last() {
if last_tag == closing_tag_name {
tag_stack.pop();
} else {
return XmlValidateResult {
is_valid: false,
error_message: Some(format!(
"不匹配的闭合标签: </{}> (期望: </{}>)",
closing_tag_name, last_tag
)),
error_line: Some(line),
};
}
} else {
return XmlValidateResult {
is_valid: false,
error_message: Some(format!("不匹配的闭合标签: </{}> (没有对应的开始标签)", closing_tag_name)),
error_line: Some(line),
};
}
} else {
// 提取标签名(去掉属性)
if let Some(tag_name) = tag.split_whitespace().next() {
tag_stack.push(tag_name.to_string());
}
}
continue;
}
// 收集标签内的所有字符(包括空格,以便后续正确分割属性)
current_tag.push(c);
}
}
if tag_stack.is_empty() {
XmlValidateResult {
match roxmltree::Document::parse(input) {
Ok(_) => XmlValidateResult {
is_valid: true,
error_message: None,
error_line: None,
}
} else {
},
Err(e) => {
// roxmltree 提供了详细的错误位置信息
let error_line = Some(e.pos().row as usize);
XmlValidateResult {
is_valid: false,
error_message: Some(format!("未闭合的标签: {}", tag_stack.join(", "))),
error_line: Some(line),
error_message: Some(e.to_string()),
error_line,
}
}
}
}
@@ -424,25 +128,72 @@ pub struct XmlValidateResult {
pub error_line: Option<usize>,
}
/// 添加换行和缩进
fn add_newline(result: &mut String, indent_level: u32, indent_str: &str) {
if !result.ends_with('\n') && !result.is_empty() {
result.push('\n');
result.push_str(&indent_str.repeat(indent_level as usize));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prettify_xml() {
fn test_prettify_simple_xml() {
let input = "<root><item>test</item></root>";
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains('\n'));
assert!(result.contains(" "));
println!("Formatted:\n{}", result);
}
#[test]
fn test_prettify_xml_with_attributes() {
let input = r#"<?xml version="1.0" encoding="UTF-8"?><root><item id="1"><name>示例</name></item></root>"#;
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains('\n'));
println!("Formatted with attributes:\n{}", result);
}
#[test]
fn test_prettify_xml_with_namespace() {
let input = r#"<ns:root xmlns:ns="http://example.com"><ns:item>test</ns:item></ns:root>"#;
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains('\n'));
assert!(result.contains("ns:"));
println!("Formatted with namespace:\n{}", result);
}
#[test]
fn test_prettify_xml_with_cdata() {
let input = r#"<root><data><![CDATA[<special>data</special>]]></data></root>"#;
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains("<![CDATA["));
println!("Formatted with CDATA:\n{}", result);
}
#[test]
fn test_prettify_xml_with_mixed_content() {
let input = "<p>Hello <b>world</b> text</p>";
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains('\n'));
println!("Formatted with mixed content:\n{}", result);
}
#[test]
fn test_prettify_xml_with_comments() {
let input = "<root><!-- comment --><item>test</item></root>";
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains("<!--"));
println!("Formatted with comments:\n{}", result);
}
#[test]
fn test_prettify_xml_with_gt_in_attribute() {
let input = r#"<root><item text="1 > 2">test</item></root>"#;
let config = XmlFormatConfig::default();
let result = format_xml(input, &config).unwrap();
assert!(result.contains(r#"text="1 > 2""#));
println!("Formatted with > in attribute:\n{}", result);
}
#[test]
@@ -453,7 +204,8 @@ mod tests {
..Default::default()
};
let result = format_xml(input, &config).unwrap();
assert!(!result.contains(" "));
assert!(!result.contains(" ")); // 不应有多个空格
println!("Compacted:\n{}", result);
}
#[test]
@@ -462,31 +214,40 @@ mod tests {
assert!(result.is_valid);
}
#[test]
fn test_validate_xml_valid_with_attributes() {
let input = r#"<?xml version="1.0" encoding="UTF-8"?><root><item id="1"><name>示例</name></item></root>"#;
let result = validate_xml(input);
assert!(result.is_valid);
}
#[test]
fn test_validate_xml_valid_with_namespace() {
let input = r#"<ns:root xmlns:ns="http://example.com"><ns:item>test</ns:item></ns:root>"#;
let result = validate_xml(input);
assert!(result.is_valid);
}
#[test]
fn test_validate_xml_invalid() {
let result = validate_xml("<root><item></root>");
assert!(!result.is_valid);
assert!(result.error_message.is_some());
}
#[test]
fn test_validate_xml_with_attributes() {
// 测试带属性的XML验证用户报告的案例
let input = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><item id=\"1\"><name>示例</name><value>测试</value></item></root>";
fn test_validate_xml_with_entities() {
let input = "<root><item>&lt;tag&gt;</item></root>";
let result = validate_xml(input);
assert!(result.is_valid, "XML with attributes should be valid");
assert!(result.is_valid);
}
#[test]
fn test_validate_xml_with_multiple_attributes() {
let input = "<root><item id=\"1\" name=\"test\" value=\"123\"></item></root>";
let result = validate_xml(input);
assert!(result.is_valid, "XML with multiple attributes should be valid");
}
#[test]
fn test_validate_xml_nested_with_attributes() {
let input = "<root id=\"main\"><parent id=\"1\"><child id=\"2\"></child></parent></root>";
let result = validate_xml(input);
assert!(result.is_valid, "Nested XML with attributes should be valid");
fn test_validate_xml_unclosed_tag() {
let result = validate_xml("<root><item>");
assert!(!result.is_valid);
if let Some(line) = result.error_line {
println!("Error at line: {}", line);
}
}
}

View File

@@ -1,12 +1,130 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CodeHighlighter } from '@/components/ui/code-highlighter';
import { Copy, Check, FileCode, Sparkles, Minimize2, CheckCircle2, XCircle, Upload, ChevronLeft, ChevronRight } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Copy, Check, FileCode, Sparkles, Minimize2, CheckCircle2, XCircle, Upload, ChevronLeft, ChevronRight, ChevronDown, Braces } from 'lucide-react';
import type { JsonFormatConfig, JsonFormatResult, JsonValidateResult } from '@/types/json';
type JsonTreeValue = null | boolean | number | string | JsonTreeValue[] | { [key: string]: JsonTreeValue };
function isExpandable(value: JsonTreeValue): value is JsonTreeValue[] | { [key: string]: JsonTreeValue } {
return typeof value === 'object' && value !== null;
}
function collectExpandablePaths(value: JsonTreeValue, path = 'root'): string[] {
if (!isExpandable(value)) {
return [];
}
const childEntries = Array.isArray(value)
? value.map((item, index) => [String(index), item] as const)
: Object.entries(value);
return [
path,
...childEntries.flatMap(([key, child]) => collectExpandablePaths(child, `${path}.${key}`)),
];
}
function getNodePreview(value: JsonTreeValue): string {
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
if (value && typeof value === 'object') {
return `Object(${Object.keys(value).length})`;
}
if (typeof value === 'string') {
return `"${value}"`;
}
return String(value);
}
function getValueClassName(value: JsonTreeValue): string {
switch (typeof value) {
case 'string':
return 'text-emerald-600 dark:text-emerald-400';
case 'number':
return 'text-sky-600 dark:text-sky-400';
case 'boolean':
return 'text-amber-600 dark:text-amber-400';
default:
return value === null ? 'text-muted-foreground' : 'text-foreground';
}
}
interface JsonTreeNodeProps {
label: string;
value: JsonTreeValue;
path: string;
depth?: number;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
}
function JsonTreeNode({ label, value, path, depth = 0, expandedPaths, onToggle }: JsonTreeNodeProps) {
const expandable = isExpandable(value);
const isExpanded = expandedPaths.has(path);
const children = expandable
? (Array.isArray(value)
? value.map((item, index) => [String(index), item] as const)
: Object.entries(value))
: [];
return (
<div>
<div
className="flex items-start gap-1.5 rounded-md px-2 py-1 hover:bg-muted/50"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{expandable ? (
<button
type="button"
onClick={() => onToggle(path)}
className="mt-0.5 text-muted-foreground hover:text-foreground"
aria-label={isExpanded ? '收起节点' : '展开节点'}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="h-4 w-4 flex-shrink-0" />
)}
<div className="min-w-0 flex-1 font-mono text-sm leading-6">
<span className="text-violet-600 dark:text-violet-400">{label}</span>
<span className="text-muted-foreground">: </span>
{expandable ? (
<span className="text-muted-foreground">{getNodePreview(value)}</span>
) : (
<span className={getValueClassName(value)}>{getNodePreview(value)}</span>
)}
</div>
</div>
{expandable && isExpanded && (
<div>
{children.map(([childLabel, childValue]) => (
<JsonTreeNode
key={`${path}.${childLabel}`}
label={Array.isArray(value) ? `[${childLabel}]` : childLabel}
value={childValue}
path={`${path}.${childLabel}`}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
export function JsonFormatterPage() {
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
@@ -19,6 +137,20 @@ export function JsonFormatterPage() {
const [copied, setCopied] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isInputCollapsed, setIsInputCollapsed] = useState(false);
const [resultView, setResultView] = useState<'code' | 'tree'>('code');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const parsedOutput = useMemo<JsonTreeValue | null>(() => {
if (!output) {
return null;
}
try {
return JSON.parse(output) as JsonTreeValue;
} catch {
return null;
}
}, [output]);
// 监听输入变化,自动验证
useEffect(() => {
@@ -61,6 +193,7 @@ export function JsonFormatterPage() {
if (result.success) {
setOutput(result.result);
setExpandedPaths(new Set());
// 格式化成功后自动收起输入区域
setIsInputCollapsed(true);
} else {
@@ -88,6 +221,7 @@ export function JsonFormatterPage() {
if (result.success) {
setOutput(result.result);
setExpandedPaths(new Set());
} else {
setOutput(result.error || '压缩失败');
}
@@ -117,6 +251,7 @@ export function JsonFormatterPage() {
setInput('');
setOutput('');
setValidation(null);
setExpandedPaths(new Set());
}, []);
// 使用示例
@@ -134,6 +269,30 @@ export function JsonFormatterPage() {
setInput(JSON.stringify(example));
}, []);
const toggleNode = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandAllNodes = useCallback(() => {
if (!parsedOutput) {
return;
}
setExpandedPaths(new Set(collectExpandablePaths(parsedOutput)));
}, [parsedOutput]);
const collapseAllNodes = useCallback(() => {
setExpandedPaths(new Set());
}, []);
return (
<div className="flex flex-col h-screen bg-background">
{/* 顶部导航栏 */}
@@ -344,6 +503,17 @@ export function JsonFormatterPage() {
<CardTitle className="text-lg"></CardTitle>
<CardDescription> JSON</CardDescription>
</div>
<div className="flex items-center gap-2">
{output && parsedOutput && resultView === 'tree' && (
<>
<Button size="sm" variant="outline" onClick={expandAllNodes}>
</Button>
<Button size="sm" variant="outline" onClick={collapseAllNodes}>
</Button>
</>
)}
{output && (
<Button
size="sm"
@@ -365,8 +535,22 @@ export function JsonFormatterPage() {
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={resultView} onValueChange={(value) => setResultView(value as 'code' | 'tree')}>
<TabsList className="mb-4">
<TabsTrigger value="code" className="gap-2">
<FileCode className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="tree" className="gap-2" disabled={!parsedOutput}>
<Braces className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="code">
<CodeHighlighter
code={output}
language="json"
@@ -375,6 +559,26 @@ export function JsonFormatterPage() {
showLineNumbers={true}
wrapLongLines={false}
/>
</TabsContent>
<TabsContent value="tree">
{parsedOutput ? (
<div className="max-h-96 overflow-auto rounded-lg border bg-muted/20 py-2">
<JsonTreeNode
label="root"
value={parsedOutput}
path="root"
expandedPaths={expandedPaths}
onToggle={toggleNode}
/>
</div>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border bg-muted/20 text-sm text-muted-foreground">
JSON
</div>
)}
</TabsContent>
</Tabs>
{/* 统计信息 */}
{output && (