feat: 新增 CSS 和 Rust 专业代码格式化器
集成 lightningcss 和 syn 库,为代码格式化工具添加 CSS 和 Rust 语言的专业格式化能力。同时重构 XML 格式化器,使用 roxmltree 和 quick-xml 替代纯函数实现,提升解析准确性和性能。
This commit is contained in:
52
src-tauri/Cargo.lock
generated
52
src-tauri/Cargo.lock
generated
@@ -579,7 +579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
|
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1111,7 +1111,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -2462,9 +2462,9 @@ checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
version = "0.4.10"
|
version = "0.4.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -2813,9 +2813,9 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -3442,7 +3442,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3667,6 +3667,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -4054,6 +4064,12 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4740,12 +4756,16 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"html5ever 0.27.0",
|
"html5ever 0.27.0",
|
||||||
"image",
|
"image",
|
||||||
|
"lightningcss",
|
||||||
"markup_fmt",
|
"markup_fmt",
|
||||||
"minify-html",
|
"minify-html",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"roxmltree",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"syn 2.0.114",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -4774,7 +4794,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"tauri-winres",
|
"tauri-winres",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4832,7 +4852,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4872,7 +4892,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4996,7 +5016,7 @@ dependencies = [
|
|||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -5011,7 +5031,7 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5198,9 +5218,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -5267,9 +5287,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 0.7.14",
|
"winnow 0.7.14",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ markup_fmt = "0.18"
|
|||||||
minify-html = "0.15"
|
minify-html = "0.15"
|
||||||
html5ever = "0.27"
|
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]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.58", features = [
|
windows = { version = "0.58", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
|
|||||||
37
src-tauri/src/models/css_format.rs
Normal file
37
src-tauri/src/models/css_format.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
pub mod code_format;
|
pub mod code_format;
|
||||||
pub mod color;
|
pub mod color;
|
||||||
|
pub mod css_format;
|
||||||
pub mod html_format;
|
pub mod html_format;
|
||||||
pub mod json_format;
|
pub mod json_format;
|
||||||
pub mod qrcode;
|
pub mod qrcode;
|
||||||
|
pub mod rust_format;
|
||||||
pub mod system_info;
|
pub mod system_info;
|
||||||
pub mod xml_format;
|
pub mod xml_format;
|
||||||
|
|||||||
37
src-tauri/src/models/rust_format.rs
Normal file
37
src-tauri/src/models/rust_format.rs
Normal 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 {
|
||||||
|
/// 缩进空格数(默认 4,Rust 标准)
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
@@ -62,6 +62,24 @@ fn prettify_code(input: &str, config: &CodeFormatConfig) -> Result<String, Strin
|
|||||||
};
|
};
|
||||||
html_formatter::format_html(input, &html_config)
|
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)
|
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)
|
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)
|
generic_compact(input)
|
||||||
|
|||||||
196
src-tauri/src/utils/css_formatter.rs
Normal file
196
src-tauri/src/utils/css_formatter.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
pub mod code_formatter;
|
pub mod code_formatter;
|
||||||
pub mod color_conversion;
|
pub mod color_conversion;
|
||||||
|
pub mod css_formatter;
|
||||||
pub mod html_formatter;
|
pub mod html_formatter;
|
||||||
pub mod json_formatter;
|
pub mod json_formatter;
|
||||||
pub mod qrcode_renderer;
|
pub mod qrcode_renderer;
|
||||||
|
pub mod rust_formatter;
|
||||||
pub mod screen;
|
pub mod screen;
|
||||||
pub mod shortcut;
|
pub mod shortcut;
|
||||||
pub mod xml_formatter;
|
pub mod xml_formatter;
|
||||||
|
|||||||
351
src-tauri/src/utils/rust_formatter.rs
Normal file
351
src-tauri/src/utils/rust_formatter.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! XML 格式化工具函数
|
//! XML 格式化工具函数
|
||||||
//!
|
//!
|
||||||
//! 提供纯函数的 XML 处理算法
|
//! 使用专业解析库(roxmltree + quick-xml)进行 XML 处理
|
||||||
|
|
||||||
use crate::models::xml_format::{FormatMode, XmlFormatConfig};
|
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> {
|
fn prettify_xml(input: &str, indent_size: u32) -> Result<String, String> {
|
||||||
let indent_str = " ".repeat(indent_size as usize);
|
use quick_xml::reader::Reader;
|
||||||
let mut result = String::new();
|
use quick_xml::writer::Writer;
|
||||||
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();
|
|
||||||
|
|
||||||
while let Some(c) = chars.next() {
|
let mut reader = Reader::from_str(input);
|
||||||
// 处理 CDATA
|
reader.config_mut().trim_text(true);
|
||||||
if c == '<' && chars.peek() == Some(&'!') {
|
let writer = Writer::new_with_indent(Vec::new(), b' ', indent_size as usize);
|
||||||
let mut next_chars = chars.clone();
|
rewrite_xml(&mut reader, writer)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 压缩 XML 字符串
|
/// 压缩 XML 字符串
|
||||||
/// 压缩 XML 字符串(公开函数)
|
|
||||||
pub fn compact_xml(input: &str) -> Result<String, String> {
|
pub fn compact_xml(input: &str) -> Result<String, String> {
|
||||||
let mut result = String::new();
|
use quick_xml::reader::Reader;
|
||||||
let mut chars = input.chars().peekable();
|
use quick_xml::writer::Writer;
|
||||||
let mut in_tag = false;
|
|
||||||
let mut in_comment = false;
|
|
||||||
let mut in_cdata = false;
|
|
||||||
|
|
||||||
while let Some(c) = chars.next() {
|
let mut reader = Reader::from_str(input);
|
||||||
// 处理 CDATA
|
reader.config_mut().trim_text(true);
|
||||||
if c == '<' && chars.peek() == Some(&'!') {
|
|
||||||
let mut next_chars = chars.clone();
|
let writer = Writer::new(Vec::new());
|
||||||
next_chars.next();
|
rewrite_xml(&mut reader, writer)
|
||||||
if next_chars.peek() == Some(&'[') {
|
}
|
||||||
let mut temp = String::new();
|
|
||||||
for _ in 0..7 {
|
fn rewrite_xml(
|
||||||
if let Some(nc) = next_chars.next() {
|
reader: &mut quick_xml::reader::Reader<&[u8]>,
|
||||||
temp.push(nc);
|
mut writer: quick_xml::writer::Writer<Vec<u8>>,
|
||||||
}
|
) -> Result<String, String> {
|
||||||
}
|
use quick_xml::events::Event;
|
||||||
if temp.starts_with("[CDATA[") {
|
|
||||||
in_cdata = true;
|
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 in_cdata {
|
buf.clear();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 验证 XML 字符串
|
/// 验证 XML 字符串(使用 roxmltree)
|
||||||
pub fn validate_xml(input: &str) -> XmlValidateResult {
|
pub fn validate_xml(input: &str) -> XmlValidateResult {
|
||||||
if input.trim().is_empty() {
|
if input.trim().is_empty() {
|
||||||
return XmlValidateResult {
|
return XmlValidateResult {
|
||||||
@@ -271,147 +101,21 @@ pub fn validate_xml(input: &str) -> XmlValidateResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本 XML 验证:检查标签是否匹配
|
match roxmltree::Document::parse(input) {
|
||||||
let mut tag_stack = Vec::new();
|
Ok(_) => XmlValidateResult {
|
||||||
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 {
|
|
||||||
is_valid: true,
|
is_valid: true,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
error_line: None,
|
error_line: None,
|
||||||
}
|
},
|
||||||
} else {
|
Err(e) => {
|
||||||
XmlValidateResult {
|
// roxmltree 提供了详细的错误位置信息
|
||||||
is_valid: false,
|
let error_line = Some(e.pos().row as usize);
|
||||||
error_message: Some(format!("未闭合的标签: {}", tag_stack.join(", "))),
|
|
||||||
error_line: Some(line),
|
XmlValidateResult {
|
||||||
|
is_valid: false,
|
||||||
|
error_message: Some(e.to_string()),
|
||||||
|
error_line,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,25 +128,72 @@ pub struct XmlValidateResult {
|
|||||||
pub error_line: Option<usize>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_prettify_xml() {
|
fn test_prettify_simple_xml() {
|
||||||
let input = "<root><item>test</item></root>";
|
let input = "<root><item>test</item></root>";
|
||||||
let config = XmlFormatConfig::default();
|
let config = XmlFormatConfig::default();
|
||||||
let result = format_xml(input, &config).unwrap();
|
let result = format_xml(input, &config).unwrap();
|
||||||
assert!(result.contains('\n'));
|
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]
|
#[test]
|
||||||
@@ -453,7 +204,8 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let result = format_xml(input, &config).unwrap();
|
let result = format_xml(input, &config).unwrap();
|
||||||
assert!(!result.contains(" "));
|
assert!(!result.contains(" ")); // 不应有多个空格
|
||||||
|
println!("Compacted:\n{}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -462,31 +214,40 @@ mod tests {
|
|||||||
assert!(result.is_valid);
|
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]
|
#[test]
|
||||||
fn test_validate_xml_invalid() {
|
fn test_validate_xml_invalid() {
|
||||||
let result = validate_xml("<root><item></root>");
|
let result = validate_xml("<root><item></root>");
|
||||||
assert!(!result.is_valid);
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.error_message.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_xml_with_attributes() {
|
fn test_validate_xml_with_entities() {
|
||||||
// 测试带属性的XML验证(用户报告的案例)
|
let input = "<root><item><tag></item></root>";
|
||||||
let input = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><item id=\"1\"><name>示例</name><value>测试</value></item></root>";
|
|
||||||
let result = validate_xml(input);
|
let result = validate_xml(input);
|
||||||
assert!(result.is_valid, "XML with attributes should be valid");
|
assert!(result.is_valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_xml_with_multiple_attributes() {
|
fn test_validate_xml_unclosed_tag() {
|
||||||
let input = "<root><item id=\"1\" name=\"test\" value=\"123\"></item></root>";
|
let result = validate_xml("<root><item>");
|
||||||
let result = validate_xml(input);
|
assert!(!result.is_valid);
|
||||||
assert!(result.is_valid, "XML with multiple attributes should be valid");
|
if let Some(line) = result.error_line {
|
||||||
}
|
println!("Error at line: {}", line);
|
||||||
|
}
|
||||||
#[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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CodeHighlighter } from '@/components/ui/code-highlighter';
|
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';
|
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() {
|
export function JsonFormatterPage() {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [output, setOutput] = useState('');
|
const [output, setOutput] = useState('');
|
||||||
@@ -19,6 +137,20 @@ export function JsonFormatterPage() {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isInputCollapsed, setIsInputCollapsed] = 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(() => {
|
useEffect(() => {
|
||||||
@@ -61,6 +193,7 @@ export function JsonFormatterPage() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOutput(result.result);
|
setOutput(result.result);
|
||||||
|
setExpandedPaths(new Set());
|
||||||
// 格式化成功后自动收起输入区域
|
// 格式化成功后自动收起输入区域
|
||||||
setIsInputCollapsed(true);
|
setIsInputCollapsed(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -88,6 +221,7 @@ export function JsonFormatterPage() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOutput(result.result);
|
setOutput(result.result);
|
||||||
|
setExpandedPaths(new Set());
|
||||||
} else {
|
} else {
|
||||||
setOutput(result.error || '压缩失败');
|
setOutput(result.error || '压缩失败');
|
||||||
}
|
}
|
||||||
@@ -117,6 +251,7 @@ export function JsonFormatterPage() {
|
|||||||
setInput('');
|
setInput('');
|
||||||
setOutput('');
|
setOutput('');
|
||||||
setValidation(null);
|
setValidation(null);
|
||||||
|
setExpandedPaths(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 使用示例
|
// 使用示例
|
||||||
@@ -134,6 +269,30 @@ export function JsonFormatterPage() {
|
|||||||
setInput(JSON.stringify(example));
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-background">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
{/* 顶部导航栏 */}
|
{/* 顶部导航栏 */}
|
||||||
@@ -344,37 +503,82 @@ export function JsonFormatterPage() {
|
|||||||
<CardTitle className="text-lg">格式化结果</CardTitle>
|
<CardTitle className="text-lg">格式化结果</CardTitle>
|
||||||
<CardDescription>格式化后的 JSON</CardDescription>
|
<CardDescription>格式化后的 JSON</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{output && (
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{output && parsedOutput && resultView === 'tree' && (
|
||||||
size="sm"
|
<>
|
||||||
variant="outline"
|
<Button size="sm" variant="outline" onClick={expandAllNodes}>
|
||||||
onClick={copyToClipboard}
|
全部展开
|
||||||
className="gap-2"
|
</Button>
|
||||||
>
|
<Button size="sm" variant="outline" onClick={collapseAllNodes}>
|
||||||
{copied ? (
|
全部收起
|
||||||
<>
|
</Button>
|
||||||
<Check className="w-4 h-4" />
|
</>
|
||||||
已复制
|
)}
|
||||||
</>
|
{output && (
|
||||||
) : (
|
<Button
|
||||||
<>
|
size="sm"
|
||||||
<Copy className="w-4 h-4" />
|
variant="outline"
|
||||||
复制
|
onClick={copyToClipboard}
|
||||||
</>
|
className="gap-2"
|
||||||
)}
|
>
|
||||||
</Button>
|
{copied ? (
|
||||||
)}
|
<>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
已复制
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
复制
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CodeHighlighter
|
<Tabs value={resultView} onValueChange={(value) => setResultView(value as 'code' | 'tree')}>
|
||||||
code={output}
|
<TabsList className="mb-4">
|
||||||
language="json"
|
<TabsTrigger value="code" className="gap-2">
|
||||||
className="w-full"
|
<FileCode className="h-4 w-4" />
|
||||||
maxHeight="24rem"
|
代码
|
||||||
showLineNumbers={true}
|
</TabsTrigger>
|
||||||
wrapLongLines={false}
|
<TabsTrigger value="tree" className="gap-2" disabled={!parsedOutput}>
|
||||||
/>
|
<Braces className="h-4 w-4" />
|
||||||
|
树结构
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="code">
|
||||||
|
<CodeHighlighter
|
||||||
|
code={output}
|
||||||
|
language="json"
|
||||||
|
className="w-full"
|
||||||
|
maxHeight="24rem"
|
||||||
|
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 && (
|
{output && (
|
||||||
|
|||||||
Reference in New Issue
Block a user