@tishlang/tish-format 1.0.12 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +51 -0
- package/LICENSE +13 -0
- package/bin/tish-format +0 -0
- package/crates/js_to_tish/Cargo.toml +11 -0
- package/crates/js_to_tish/README.md +18 -0
- package/crates/js_to_tish/src/error.rs +55 -0
- package/crates/js_to_tish/src/lib.rs +11 -0
- package/crates/js_to_tish/src/span_util.rs +35 -0
- package/crates/js_to_tish/src/transform/expr.rs +611 -0
- package/crates/js_to_tish/src/transform/stmt.rs +503 -0
- package/crates/js_to_tish/src/transform.rs +60 -0
- package/crates/tish/Cargo.toml +62 -0
- package/crates/tish/build.rs +21 -0
- package/crates/tish/src/cargo_native_registry.rs +32 -0
- package/crates/tish/src/cli_help.rs +576 -0
- package/crates/tish/src/main.rs +853 -0
- package/crates/tish/src/repl_completion.rs +199 -0
- package/crates/tish/tests/cargo_example_compile.rs +67 -0
- package/crates/tish/tests/error_source_location.rs +36 -0
- package/crates/tish/tests/fixtures/cargo_example_project/Cargo.toml +3 -0
- package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/Cargo.toml +11 -0
- package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +12 -0
- package/crates/tish/tests/fixtures/cargo_example_project/package.json +10 -0
- package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +3 -0
- package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
- package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
- package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
- package/crates/tish/tests/integration_test.rs +1406 -0
- package/crates/tish/tests/run_optimize_stdout_parity.rs +50 -0
- package/crates/tish/tests/shortcircuit.rs +65 -0
- package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
- package/crates/tish/tests/tty_capability.rs +43 -0
- package/crates/tish_ast/Cargo.toml +9 -0
- package/crates/tish_ast/src/ast.rs +649 -0
- package/crates/tish_ast/src/lib.rs +5 -0
- package/crates/tish_build_utils/Cargo.toml +11 -0
- package/crates/tish_build_utils/src/lib.rs +577 -0
- package/crates/tish_builtins/Cargo.toml +22 -0
- package/crates/tish_builtins/src/array.rs +803 -0
- package/crates/tish_builtins/src/collections.rs +481 -0
- package/crates/tish_builtins/src/construct.rs +199 -0
- package/crates/tish_builtins/src/date.rs +538 -0
- package/crates/tish_builtins/src/globals.rs +293 -0
- package/crates/tish_builtins/src/helpers.rs +35 -0
- package/crates/tish_builtins/src/iterator.rs +129 -0
- package/crates/tish_builtins/src/lib.rs +21 -0
- package/crates/tish_builtins/src/math.rs +89 -0
- package/crates/tish_builtins/src/number.rs +96 -0
- package/crates/tish_builtins/src/object.rs +36 -0
- package/crates/tish_builtins/src/string.rs +646 -0
- package/crates/tish_builtins/src/symbol.rs +83 -0
- package/crates/tish_builtins/src/typedarrays.rs +298 -0
- package/crates/tish_bytecode/Cargo.toml +17 -0
- package/crates/tish_bytecode/src/chunk.rs +164 -0
- package/crates/tish_bytecode/src/compiler.rs +2604 -0
- package/crates/tish_bytecode/src/encoding.rs +102 -0
- package/crates/tish_bytecode/src/lib.rs +20 -0
- package/crates/tish_bytecode/src/opcode.rs +185 -0
- package/crates/tish_bytecode/src/peephole.rs +189 -0
- package/crates/tish_bytecode/src/serialize.rs +193 -0
- package/crates/tish_bytecode/tests/break_continue_bytecode.rs +44 -0
- package/crates/tish_bytecode/tests/constant_folding.rs +84 -0
- package/crates/tish_bytecode/tests/sort_optimization.rs +31 -0
- package/crates/tish_compile/Cargo.toml +27 -0
- package/crates/tish_compile/src/check.rs +774 -0
- package/crates/tish_compile/src/codegen.rs +7317 -0
- package/crates/tish_compile/src/infer.rs +1681 -0
- package/crates/tish_compile/src/lib.rs +206 -0
- package/crates/tish_compile/src/resolve.rs +1951 -0
- package/crates/tish_compile/src/types.rs +605 -0
- package/crates/tish_compile_js/Cargo.toml +18 -0
- package/crates/tish_compile_js/examples/jsx_vdom_smoke.tish +8 -0
- package/crates/tish_compile_js/src/codegen.rs +938 -0
- package/crates/tish_compile_js/src/error.rs +20 -0
- package/crates/tish_compile_js/src/lib.rs +26 -0
- package/crates/tish_compile_js/src/tests_jsx.rs +414 -0
- package/crates/tish_compiler_wasm/Cargo.toml +21 -0
- package/crates/tish_compiler_wasm/src/lib.rs +57 -0
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +473 -0
- package/crates/tish_core/Cargo.toml +32 -0
- package/crates/tish_core/src/console_style.rs +170 -0
- package/crates/tish_core/src/json.rs +430 -0
- package/crates/tish_core/src/lib.rs +20 -0
- package/crates/tish_core/src/macros.rs +36 -0
- package/crates/tish_core/src/shape.rs +85 -0
- package/crates/tish_core/src/uri.rs +118 -0
- package/crates/tish_core/src/value.rs +1350 -0
- package/crates/tish_core/src/vmref.rs +183 -0
- package/crates/tish_cranelift/Cargo.toml +19 -0
- package/crates/tish_cranelift/src/lib.rs +43 -0
- package/crates/tish_cranelift/src/link.rs +130 -0
- package/crates/tish_cranelift/src/lower.rs +85 -0
- package/crates/tish_cranelift_runtime/Cargo.toml +26 -0
- package/crates/tish_cranelift_runtime/src/lib.rs +45 -0
- package/crates/tish_eval/Cargo.toml +51 -0
- package/crates/tish_eval/src/eval.rs +4265 -0
- package/crates/tish_eval/src/http.rs +191 -0
- package/crates/tish_eval/src/lib.rs +99 -0
- package/crates/tish_eval/src/natives.rs +551 -0
- package/crates/tish_eval/src/promise.rs +179 -0
- package/crates/tish_eval/src/regex.rs +299 -0
- package/crates/tish_eval/src/timers.rs +120 -0
- package/crates/tish_eval/src/value.rs +336 -0
- package/crates/tish_eval/src/value_convert.rs +117 -0
- package/crates/tish_ffi/Cargo.toml +26 -0
- package/crates/tish_ffi/src/lib.rs +518 -0
- package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
- package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
- package/crates/tish_ffi/tests/loader.rs +65 -0
- package/crates/tish_fmt/Cargo.toml +16 -0
- package/crates/tish_fmt/src/bin/tish-fmt.rs +41 -0
- package/crates/tish_fmt/src/lib.rs +2157 -0
- package/crates/tish_jsx_web/Cargo.toml +9 -0
- package/crates/tish_jsx_web/README.md +5 -0
- package/crates/tish_jsx_web/src/lib.rs +2 -0
- package/crates/tish_lexer/Cargo.toml +9 -0
- package/crates/tish_lexer/src/lib.rs +1104 -0
- package/crates/tish_lexer/src/token.rs +170 -0
- package/crates/tish_lint/Cargo.toml +18 -0
- package/crates/tish_lint/src/bin/tish-lint.rs +195 -0
- package/crates/tish_lint/src/lib.rs +281 -0
- package/crates/tish_llvm/Cargo.toml +13 -0
- package/crates/tish_llvm/src/lib.rs +115 -0
- package/crates/tish_lsp/Cargo.toml +25 -0
- package/crates/tish_lsp/README.md +26 -0
- package/crates/tish_lsp/src/builtin_goto.rs +362 -0
- package/crates/tish_lsp/src/import_goto.rs +564 -0
- package/crates/tish_lsp/src/main.rs +1459 -0
- package/crates/tish_native/Cargo.toml +16 -0
- package/crates/tish_native/src/build.rs +481 -0
- package/crates/tish_native/src/config.rs +48 -0
- package/crates/tish_native/src/lib.rs +416 -0
- package/crates/tish_opt/Cargo.toml +13 -0
- package/crates/tish_opt/src/lib.rs +1046 -0
- package/crates/tish_parser/Cargo.toml +11 -0
- package/crates/tish_parser/src/lib.rs +386 -0
- package/crates/tish_parser/src/parser.rs +2726 -0
- package/crates/tish_pg/Cargo.toml +34 -0
- package/crates/tish_pg/README.md +38 -0
- package/crates/tish_pg/src/error.rs +52 -0
- package/crates/tish_pg/src/lib.rs +955 -0
- package/crates/tish_resolve/Cargo.toml +13 -0
- package/crates/tish_resolve/src/lib.rs +3601 -0
- package/crates/tish_resolve/src/pos.rs +141 -0
- package/crates/tish_runtime/Cargo.toml +100 -0
- package/crates/tish_runtime/src/http.rs +1347 -0
- package/crates/tish_runtime/src/http_fetch.rs +492 -0
- package/crates/tish_runtime/src/http_hyper.rs +441 -0
- package/crates/tish_runtime/src/http_prefork.rs +189 -0
- package/crates/tish_runtime/src/lib.rs +1447 -0
- package/crates/tish_runtime/src/native_promise.rs +15 -0
- package/crates/tish_runtime/src/promise.rs +558 -0
- package/crates/tish_runtime/src/promise_io.rs +38 -0
- package/crates/tish_runtime/src/timers.rs +172 -0
- package/crates/tish_runtime/src/tty.rs +226 -0
- package/crates/tish_runtime/src/ws.rs +778 -0
- package/crates/tish_runtime/tests/fetch_readable_stream.rs +102 -0
- package/crates/tish_ui/Cargo.toml +17 -0
- package/crates/tish_ui/src/jsx.rs +692 -0
- package/crates/tish_ui/src/lib.rs +20 -0
- package/crates/tish_ui/src/runtime/hooks.rs +573 -0
- package/crates/tish_ui/src/runtime/mod.rs +183 -0
- package/crates/tish_vm/Cargo.toml +60 -0
- package/crates/tish_vm/src/jit.rs +1050 -0
- package/crates/tish_vm/src/lib.rs +41 -0
- package/crates/tish_vm/src/vm.rs +3536 -0
- package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
- package/crates/tish_vm/tests/fixtures/or_string_cmd.tish +2 -0
- package/crates/tish_vm/tests/lexical_scope_declare.rs +34 -0
- package/crates/tish_vm/tests/peephole_jump_chain_logical_or.rs +150 -0
- package/crates/tish_wasm/Cargo.toml +15 -0
- package/crates/tish_wasm/src/lib.rs +428 -0
- package/crates/tish_wasm_runtime/Cargo.toml +37 -0
- package/crates/tish_wasm_runtime/src/gpu.rs +429 -0
- package/crates/tish_wasm_runtime/src/lib.rs +42 -0
- package/crates/tishlang_cargo_bindgen/Cargo.toml +26 -0
- package/crates/tishlang_cargo_bindgen/src/classify.rs +261 -0
- package/crates/tishlang_cargo_bindgen/src/discover.rs +125 -0
- package/crates/tishlang_cargo_bindgen/src/infer.rs +382 -0
- package/crates/tishlang_cargo_bindgen/src/lib.rs +349 -0
- package/crates/tishlang_cargo_bindgen/src/main.rs +167 -0
- package/crates/tishlang_cargo_bindgen/src/metadata.rs +117 -0
- package/justfile +276 -0
- package/package.json +2 -2
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
//! JSON parsing and stringification for Tish values.
|
|
2
|
+
|
|
3
|
+
use crate::{Value, VmRef};
|
|
4
|
+
use std::sync::Arc;
|
|
5
|
+
|
|
6
|
+
/// Parse JSON string into a Value.
|
|
7
|
+
pub fn json_parse(json: &str) -> Result<Value, String> {
|
|
8
|
+
let json = json.trim();
|
|
9
|
+
if json.is_empty() {
|
|
10
|
+
return Err("SyntaxError: Unexpected end of JSON input".to_string());
|
|
11
|
+
}
|
|
12
|
+
let (value, rest) = parse_value(json, 0)?;
|
|
13
|
+
if !rest.trim().is_empty() {
|
|
14
|
+
return Err("SyntaxError: Unexpected token at end of JSON".to_string());
|
|
15
|
+
}
|
|
16
|
+
Ok(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Stringify a Value to JSON.
|
|
20
|
+
///
|
|
21
|
+
/// Single-buffer write strategy: all nested values append into one
|
|
22
|
+
/// `String` via [`json_stringify_into`], so we never allocate a transient
|
|
23
|
+
/// per-node `String` only to copy + drop it on the way back up. For a
|
|
24
|
+
/// 20-row TFB `/queries` response (~40 numbers, 2 keys × 20 = ~80 string
|
|
25
|
+
/// ops) that saves dozens of small allocations per request.
|
|
26
|
+
pub fn json_stringify(value: &Value) -> String {
|
|
27
|
+
// 256 B is enough for typical TFB responses (`/db` is 31 B,
|
|
28
|
+
// `/queries=20` is ~700 B). Larger payloads reallocate normally.
|
|
29
|
+
let mut buf = String::with_capacity(256);
|
|
30
|
+
json_stringify_into(&mut buf, value);
|
|
31
|
+
buf
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Append a JSON-stringified `value` to `buf`. Used by JSON.stringify for
|
|
35
|
+
/// the recursive case so we don't pay for an intermediate `String` per
|
|
36
|
+
/// node.
|
|
37
|
+
pub fn json_stringify_into(buf: &mut String, value: &Value) {
|
|
38
|
+
match value {
|
|
39
|
+
Value::Null => buf.push_str("null"),
|
|
40
|
+
Value::Bool(true) => buf.push_str("true"),
|
|
41
|
+
Value::Bool(false) => buf.push_str("false"),
|
|
42
|
+
Value::Number(n) => {
|
|
43
|
+
if n.is_nan() || n.is_infinite() {
|
|
44
|
+
buf.push_str("null");
|
|
45
|
+
} else {
|
|
46
|
+
// `write!` avoids the heap allocation that `to_string`
|
|
47
|
+
// produces. The f64 → decimal formatter is the same
|
|
48
|
+
// either way (`std::fmt::Display`).
|
|
49
|
+
use std::fmt::Write;
|
|
50
|
+
let _ = write!(buf, "{}", n);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
Value::String(s) => {
|
|
54
|
+
buf.push('"');
|
|
55
|
+
escape_json_string_into(buf, s);
|
|
56
|
+
buf.push('"');
|
|
57
|
+
}
|
|
58
|
+
Value::Array(arr) => {
|
|
59
|
+
let borrowed = arr.borrow();
|
|
60
|
+
buf.push('[');
|
|
61
|
+
for (i, item) in borrowed.iter().enumerate() {
|
|
62
|
+
if i > 0 {
|
|
63
|
+
buf.push(',');
|
|
64
|
+
}
|
|
65
|
+
json_stringify_into(buf, item);
|
|
66
|
+
}
|
|
67
|
+
buf.push(']');
|
|
68
|
+
}
|
|
69
|
+
Value::NumberArray(arr) => {
|
|
70
|
+
let borrowed = arr.borrow();
|
|
71
|
+
buf.push('[');
|
|
72
|
+
use std::fmt::Write;
|
|
73
|
+
for (i, n) in borrowed.iter().enumerate() {
|
|
74
|
+
if i > 0 {
|
|
75
|
+
buf.push(',');
|
|
76
|
+
}
|
|
77
|
+
if n.is_nan() || n.is_infinite() {
|
|
78
|
+
buf.push_str("null");
|
|
79
|
+
} else {
|
|
80
|
+
let _ = write!(buf, "{}", n);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
buf.push(']');
|
|
84
|
+
}
|
|
85
|
+
Value::Object(obj) => {
|
|
86
|
+
let borrowed = obj.borrow();
|
|
87
|
+
// Iterate in insertion order (PropMap preserves it) — matches JS/Node
|
|
88
|
+
// and `Object.keys`. No intermediate key Vec, no sort: one fewer
|
|
89
|
+
// allocation per object on the JSON hot path (e.g. TFB /json, /db).
|
|
90
|
+
buf.push('{');
|
|
91
|
+
for (i, (key, val)) in borrowed.strings.iter().enumerate() {
|
|
92
|
+
if i > 0 {
|
|
93
|
+
buf.push(',');
|
|
94
|
+
}
|
|
95
|
+
buf.push('"');
|
|
96
|
+
escape_json_string_into(buf, key);
|
|
97
|
+
buf.push_str("\":");
|
|
98
|
+
json_stringify_into(buf, val);
|
|
99
|
+
}
|
|
100
|
+
buf.push('}');
|
|
101
|
+
}
|
|
102
|
+
Value::Function(_) | Value::Promise(_) | Value::Opaque(_) | Value::Symbol(_) => {
|
|
103
|
+
buf.push_str("null");
|
|
104
|
+
}
|
|
105
|
+
#[cfg(feature = "regex")]
|
|
106
|
+
Value::RegExp(_) => buf.push_str("null"),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Append an escaped JSON string body (without the surrounding quotes)
|
|
111
|
+
/// to `buf`. Optimised for the common case where the input is ASCII and
|
|
112
|
+
/// contains no characters that need escaping — we fast-pass the bytes
|
|
113
|
+
/// straight through, only falling into the per-char path on a hit.
|
|
114
|
+
fn escape_json_string_into(buf: &mut String, s: &str) {
|
|
115
|
+
let bytes = s.as_bytes();
|
|
116
|
+
let mut start = 0usize;
|
|
117
|
+
for (i, &b) in bytes.iter().enumerate() {
|
|
118
|
+
// Anything < 0x20 is a JSON control char that must be escaped;
|
|
119
|
+
// 0x22 (`"`) and 0x5C (`\`) also need an explicit escape; bytes
|
|
120
|
+
// ≥ 0x80 are the start of a multi-byte UTF-8 sequence, which is
|
|
121
|
+
// valid JSON as-is.
|
|
122
|
+
if b < 0x20 || b == b'"' || b == b'\\' {
|
|
123
|
+
// Flush the run of clean bytes before this one in one push.
|
|
124
|
+
if start < i {
|
|
125
|
+
// SAFETY: `s` is `&str`, every byte in `start..i` was a
|
|
126
|
+
// single-byte ASCII char (we only stop on ASCII triggers
|
|
127
|
+
// below 0x80), so the slice is a valid `&str`.
|
|
128
|
+
buf.push_str(&s[start..i]);
|
|
129
|
+
}
|
|
130
|
+
match b {
|
|
131
|
+
b'"' => buf.push_str("\\\""),
|
|
132
|
+
b'\\' => buf.push_str("\\\\"),
|
|
133
|
+
b'\n' => buf.push_str("\\n"),
|
|
134
|
+
b'\r' => buf.push_str("\\r"),
|
|
135
|
+
b'\t' => buf.push_str("\\t"),
|
|
136
|
+
b'\x08' => buf.push_str("\\b"),
|
|
137
|
+
b'\x0c' => buf.push_str("\\f"),
|
|
138
|
+
_ => {
|
|
139
|
+
use std::fmt::Write;
|
|
140
|
+
let _ = write!(buf, "\\u{:04x}", b as u32);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
start = i + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if start < bytes.len() {
|
|
147
|
+
buf.push_str(&s[start..]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Max nesting depth for `JSON.parse`. Bounds recursion so deeply-nested untrusted
|
|
152
|
+
/// input errors instead of overflowing the stack — a Rust stack overflow aborts the
|
|
153
|
+
/// whole process (uncatchable, SIGABRT). 128 matches serde_json's default limit.
|
|
154
|
+
const MAX_JSON_DEPTH: usize = 128;
|
|
155
|
+
|
|
156
|
+
fn parse_value(input: &str, depth: usize) -> Result<(Value, &str), String> {
|
|
157
|
+
let input = input.trim_start();
|
|
158
|
+
if input.is_empty() {
|
|
159
|
+
return Err("Unexpected end of JSON input".to_string());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
match input.chars().next().unwrap() {
|
|
163
|
+
'n' => parse_null(input),
|
|
164
|
+
't' | 'f' => parse_bool(input),
|
|
165
|
+
'"' => parse_string(input),
|
|
166
|
+
'[' => parse_array(input, depth),
|
|
167
|
+
'{' => parse_object(input, depth),
|
|
168
|
+
c if c == '-' || c.is_ascii_digit() => parse_number(input),
|
|
169
|
+
c => Err(format!("Unexpected character '{}' in JSON", c)),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn parse_null(input: &str) -> Result<(Value, &str), String> {
|
|
174
|
+
if let Some(rest) = input.strip_prefix("null") {
|
|
175
|
+
Ok((Value::Null, rest))
|
|
176
|
+
} else {
|
|
177
|
+
Err("Expected 'null'".to_string())
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn parse_bool(input: &str) -> Result<(Value, &str), String> {
|
|
182
|
+
if let Some(rest) = input.strip_prefix("true") {
|
|
183
|
+
Ok((Value::Bool(true), rest))
|
|
184
|
+
} else if let Some(rest) = input.strip_prefix("false") {
|
|
185
|
+
Ok((Value::Bool(false), rest))
|
|
186
|
+
} else {
|
|
187
|
+
Err("Expected 'true' or 'false'".to_string())
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn parse_string(input: &str) -> Result<(Value, &str), String> {
|
|
192
|
+
let input = &input[1..]; // skip opening quote
|
|
193
|
+
let mut result = String::new();
|
|
194
|
+
let mut chars = input.chars().peekable();
|
|
195
|
+
let mut byte_count = 0;
|
|
196
|
+
|
|
197
|
+
loop {
|
|
198
|
+
match chars.next() {
|
|
199
|
+
None => return Err("Unterminated string".to_string()),
|
|
200
|
+
Some('"') => {
|
|
201
|
+
byte_count += 1;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
Some('\\') => {
|
|
205
|
+
byte_count += 1;
|
|
206
|
+
match chars.next() {
|
|
207
|
+
Some('n') => {
|
|
208
|
+
result.push('\n');
|
|
209
|
+
byte_count += 1;
|
|
210
|
+
}
|
|
211
|
+
Some('r') => {
|
|
212
|
+
result.push('\r');
|
|
213
|
+
byte_count += 1;
|
|
214
|
+
}
|
|
215
|
+
Some('t') => {
|
|
216
|
+
result.push('\t');
|
|
217
|
+
byte_count += 1;
|
|
218
|
+
}
|
|
219
|
+
Some('\\') => {
|
|
220
|
+
result.push('\\');
|
|
221
|
+
byte_count += 1;
|
|
222
|
+
}
|
|
223
|
+
Some('"') => {
|
|
224
|
+
result.push('"');
|
|
225
|
+
byte_count += 1;
|
|
226
|
+
}
|
|
227
|
+
Some('/') => {
|
|
228
|
+
result.push('/');
|
|
229
|
+
byte_count += 1;
|
|
230
|
+
}
|
|
231
|
+
Some('u') => {
|
|
232
|
+
byte_count += 1;
|
|
233
|
+
let mut hex = String::new();
|
|
234
|
+
for _ in 0..4 {
|
|
235
|
+
if let Some(c) = chars.next() {
|
|
236
|
+
hex.push(c);
|
|
237
|
+
byte_count += c.len_utf8();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if let Ok(n) = u32::from_str_radix(&hex, 16) {
|
|
241
|
+
if let Some(c) = char::from_u32(n) {
|
|
242
|
+
result.push(c);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
Some(c) => {
|
|
247
|
+
result.push(c);
|
|
248
|
+
byte_count += c.len_utf8();
|
|
249
|
+
}
|
|
250
|
+
None => return Err("Unterminated escape sequence".to_string()),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
Some(c) => {
|
|
254
|
+
result.push(c);
|
|
255
|
+
byte_count += c.len_utf8();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
Ok((Value::String(result.into()), &input[byte_count..]))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn parse_number(input: &str) -> Result<(Value, &str), String> {
|
|
264
|
+
// Byte scan (all number chars are ASCII) — O(token), not O(remaining input).
|
|
265
|
+
// The old `input.chars().collect::<Vec<char>>()` per number made parsing an
|
|
266
|
+
// N-number array O(N^2): a CPU-exhaustion DoS on untrusted JSON.
|
|
267
|
+
let bytes = input.as_bytes();
|
|
268
|
+
let mut end = 0;
|
|
269
|
+
|
|
270
|
+
if bytes.first() == Some(&b'-') {
|
|
271
|
+
end += 1;
|
|
272
|
+
}
|
|
273
|
+
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
|
274
|
+
end += 1;
|
|
275
|
+
}
|
|
276
|
+
if bytes.get(end) == Some(&b'.') {
|
|
277
|
+
end += 1;
|
|
278
|
+
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
|
279
|
+
end += 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if matches!(bytes.get(end), Some(&b'e') | Some(&b'E')) {
|
|
283
|
+
end += 1;
|
|
284
|
+
if matches!(bytes.get(end), Some(&b'+') | Some(&b'-')) {
|
|
285
|
+
end += 1;
|
|
286
|
+
}
|
|
287
|
+
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
|
288
|
+
end += 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// `end` lands on an ASCII boundary, so slicing `input` by byte index is valid.
|
|
293
|
+
let num_str = &input[..end];
|
|
294
|
+
num_str
|
|
295
|
+
.parse::<f64>()
|
|
296
|
+
.map(|n| (Value::Number(n), &input[end..]))
|
|
297
|
+
.map_err(|_| format!("Invalid number: {}", num_str))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fn parse_array(input: &str, depth: usize) -> Result<(Value, &str), String> {
|
|
301
|
+
if depth >= MAX_JSON_DEPTH {
|
|
302
|
+
return Err("JSON nesting too deep".to_string());
|
|
303
|
+
}
|
|
304
|
+
let mut input = &input[1..]; // skip '['
|
|
305
|
+
let mut items = Vec::new();
|
|
306
|
+
|
|
307
|
+
input = input.trim_start();
|
|
308
|
+
if let Some(rest) = input.strip_prefix(']') {
|
|
309
|
+
return Ok((Value::Array(VmRef::new(items)), rest));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
loop {
|
|
313
|
+
let (value, rest) = parse_value(input, depth + 1)?;
|
|
314
|
+
items.push(value);
|
|
315
|
+
input = rest.trim_start();
|
|
316
|
+
|
|
317
|
+
match input.chars().next() {
|
|
318
|
+
Some(',') => input = &input[1..],
|
|
319
|
+
Some(']') => return Ok((Value::Array(VmRef::new(items)), &input[1..])),
|
|
320
|
+
_ => return Err("Expected ',' or ']' in array".to_string()),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fn parse_object(input: &str, depth: usize) -> Result<(Value, &str), String> {
|
|
326
|
+
if depth >= MAX_JSON_DEPTH {
|
|
327
|
+
return Err("JSON nesting too deep".to_string());
|
|
328
|
+
}
|
|
329
|
+
let mut input = &input[1..]; // skip '{'
|
|
330
|
+
let mut map = crate::ObjectMap::default();
|
|
331
|
+
|
|
332
|
+
input = input.trim_start();
|
|
333
|
+
if let Some(rest) = input.strip_prefix('}') {
|
|
334
|
+
return Ok((
|
|
335
|
+
Value::Object(VmRef::new(crate::ObjectData::from_strings(map))),
|
|
336
|
+
rest,
|
|
337
|
+
));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
loop {
|
|
341
|
+
input = input.trim_start();
|
|
342
|
+
if !input.starts_with('"') {
|
|
343
|
+
return Err("Expected string key in object".to_string());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let (key_val, rest) = parse_string(input)?;
|
|
347
|
+
let key: Arc<str> = match key_val {
|
|
348
|
+
Value::String(s) => Arc::from(s.as_str()),
|
|
349
|
+
_ => unreachable!(),
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
input = rest.trim_start();
|
|
353
|
+
if !input.starts_with(':') {
|
|
354
|
+
return Err("Expected ':' after key in object".to_string());
|
|
355
|
+
}
|
|
356
|
+
input = &input[1..];
|
|
357
|
+
|
|
358
|
+
let (value, rest) = parse_value(input, depth + 1)?;
|
|
359
|
+
map.insert(key, value);
|
|
360
|
+
input = rest.trim_start();
|
|
361
|
+
|
|
362
|
+
match input.chars().next() {
|
|
363
|
+
Some(',') => input = &input[1..],
|
|
364
|
+
Some('}') => {
|
|
365
|
+
return Ok((
|
|
366
|
+
Value::Object(VmRef::new(crate::ObjectData::from_strings(map))),
|
|
367
|
+
&input[1..],
|
|
368
|
+
));
|
|
369
|
+
}
|
|
370
|
+
_ => return Err("Expected ',' or '}' in object".to_string()),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#[cfg(test)]
|
|
376
|
+
mod tests {
|
|
377
|
+
use super::*;
|
|
378
|
+
|
|
379
|
+
#[test]
|
|
380
|
+
fn test_parse_primitives() {
|
|
381
|
+
assert!(matches!(json_parse("null").unwrap(), Value::Null));
|
|
382
|
+
assert!(matches!(json_parse("true").unwrap(), Value::Bool(true)));
|
|
383
|
+
assert!(matches!(json_parse("false").unwrap(), Value::Bool(false)));
|
|
384
|
+
assert!(matches!(json_parse("42").unwrap(), Value::Number(n) if n == 42.0));
|
|
385
|
+
assert!(
|
|
386
|
+
matches!(json_parse("\"hello\"").unwrap(), Value::String(s) if s.as_str() == "hello")
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#[test]
|
|
391
|
+
fn test_roundtrip() {
|
|
392
|
+
let original = "{\"name\":\"test\",\"count\":42}";
|
|
393
|
+
let value = json_parse(original).unwrap();
|
|
394
|
+
let stringified = json_stringify(&value);
|
|
395
|
+
let reparsed = json_parse(&stringified).unwrap();
|
|
396
|
+
|
|
397
|
+
match (&value, &reparsed) {
|
|
398
|
+
(Value::Object(a), Value::Object(b)) => {
|
|
399
|
+
assert_eq!(a.borrow().len_entries(), b.borrow().len_entries());
|
|
400
|
+
}
|
|
401
|
+
_ => panic!("Expected objects"),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn deeply_nested_json_is_rejected_not_crash() {
|
|
407
|
+
// C1 regression: deeply-nested untrusted input must error at the depth limit,
|
|
408
|
+
// never recurse deep enough to overflow the stack (an uncatchable SIGABRT that
|
|
409
|
+
// would crash the whole process / HTTP worker).
|
|
410
|
+
let under = format!("{}{}", "[".repeat(100), "]".repeat(100));
|
|
411
|
+
assert!(json_parse(&under).is_ok(), "100 < limit should parse");
|
|
412
|
+
let over = format!("{}{}", "[".repeat(200), "]".repeat(200));
|
|
413
|
+
assert!(json_parse(&over).is_err(), "200 > limit must error");
|
|
414
|
+
// Pathological depth must still just error (fast), not overflow the stack.
|
|
415
|
+
let huge = format!("{}{}", "[".repeat(200_000), "]".repeat(200_000));
|
|
416
|
+
assert!(json_parse(&huge).is_err(), "huge depth must error, not crash");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn large_number_array_parses_correctly() {
|
|
421
|
+
// C2 regression: parse_number byte-scans (O(token)); the old chars().collect()
|
|
422
|
+
// over the whole remaining input made an N-number array O(N^2) — a CPU DoS.
|
|
423
|
+
let n = 50_000;
|
|
424
|
+
let body = format!("[{}]", vec!["7"; n].join(","));
|
|
425
|
+
match json_parse(&body).unwrap() {
|
|
426
|
+
Value::Array(arr) => assert_eq!(arr.borrow().len(), n),
|
|
427
|
+
_ => panic!("expected array"),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//! Tish Core - Shared types and utilities for the Tish language.
|
|
2
|
+
//!
|
|
3
|
+
//! This crate provides the unified Value type and utilities used by both
|
|
4
|
+
//! the interpreter (tishlang_eval) and compiled runtime (tishlang_runtime).
|
|
5
|
+
|
|
6
|
+
mod console_style;
|
|
7
|
+
mod json;
|
|
8
|
+
mod macros;
|
|
9
|
+
mod shape;
|
|
10
|
+
mod uri;
|
|
11
|
+
mod value;
|
|
12
|
+
mod vmref;
|
|
13
|
+
|
|
14
|
+
pub use console_style::{format_value_styled, format_values_for_console, use_console_colors};
|
|
15
|
+
pub use json::{json_parse, json_stringify, json_stringify_into};
|
|
16
|
+
pub use shape::{ShapeId, DICT_SHAPE, EMPTY_SHAPE};
|
|
17
|
+
pub use uri::{percent_decode, percent_encode};
|
|
18
|
+
pub use arcstr::ArcStr;
|
|
19
|
+
pub use value::*;
|
|
20
|
+
pub use vmref::{VmReadGuard, VmRef, VmWriteGuard};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//! Macros for building Tish native modules.
|
|
2
|
+
|
|
3
|
+
/// Build a Tish module object from method name => function pairs.
|
|
4
|
+
///
|
|
5
|
+
/// Each function must have signature `fn(&[Value]) -> Value` (or equivalent closure).
|
|
6
|
+
/// Pass either a `fn` pointer or a closure; the macro wraps them in `Rc::new`.
|
|
7
|
+
///
|
|
8
|
+
/// # Example
|
|
9
|
+
///
|
|
10
|
+
/// ```ignore
|
|
11
|
+
/// use tishlang_core::{tish_module, Value};
|
|
12
|
+
///
|
|
13
|
+
/// pub fn my_object() -> Value {
|
|
14
|
+
/// tish_module! {
|
|
15
|
+
/// "run" => |args: &[Value]| {
|
|
16
|
+
/// // ...
|
|
17
|
+
/// Value::Null
|
|
18
|
+
/// },
|
|
19
|
+
/// "read_csv" => my_read_csv_fn,
|
|
20
|
+
/// }
|
|
21
|
+
/// }
|
|
22
|
+
/// ```
|
|
23
|
+
#[macro_export]
|
|
24
|
+
macro_rules! tish_module {
|
|
25
|
+
($($name:expr => $fn:expr),* $(,)?) => {{
|
|
26
|
+
use std::sync::Arc;
|
|
27
|
+
use $crate::{ObjectMap, Value};
|
|
28
|
+
let mut map = ObjectMap::default();
|
|
29
|
+
$(
|
|
30
|
+
// `Value::native` picks the right Rc / Arc wrapper depending on
|
|
31
|
+
// whether the `send-values` feature is enabled upstream.
|
|
32
|
+
map.insert(Arc::from($name), Value::native($fn));
|
|
33
|
+
)*
|
|
34
|
+
Value::object(map)
|
|
35
|
+
}};
|
|
36
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//! Hidden-class "shapes" for objects — the JavaScriptCore *Structure* idea.
|
|
2
|
+
//!
|
|
3
|
+
//! A [`ShapeId`] is an interned identity for an object's **ordered string-key set**. Two objects
|
|
4
|
+
//! built by inserting the same keys in the same order share a `ShapeId`. This lets the bytecode VM's
|
|
5
|
+
//! inline caches (see `tish_bytecode::Chunk::inline_caches`) compare a single `u32` instead of hashing
|
|
6
|
+
//! a property name — on a shape hit the property is at a fixed slot index, so access is a direct load.
|
|
7
|
+
//!
|
|
8
|
+
//! Identity is **path-dependent** (like JSC): `{x,y}` and `{y,x}` are *different* shapes, because the
|
|
9
|
+
//! slot index of `x` differs — which is exactly what makes the cached `(shape, index)` correct.
|
|
10
|
+
//!
|
|
11
|
+
//! Phase 1a uses shapes only as opaque identities (the property→index lookup still goes through
|
|
12
|
+
//! `PropMap` on a cache miss). Phase 1b will attach the ordered key list to each shape so objects can
|
|
13
|
+
//! drop per-object key storage entirely (the butterfly representation).
|
|
14
|
+
|
|
15
|
+
use std::collections::HashMap;
|
|
16
|
+
use std::sync::{Arc, OnceLock, RwLock};
|
|
17
|
+
|
|
18
|
+
/// Identity of an object's ordered key-set.
|
|
19
|
+
pub type ShapeId = u32;
|
|
20
|
+
|
|
21
|
+
/// The shape of a freshly-created empty object (`{}`).
|
|
22
|
+
pub const EMPTY_SHAPE: ShapeId = 0;
|
|
23
|
+
|
|
24
|
+
/// Sentinel for objects that have opted out of shape tracking (after a property *delete*, or when the
|
|
25
|
+
/// shape space is exhausted). Such objects never match an inline cache → always the slow path. Chosen
|
|
26
|
+
/// as `u32::MAX` so it can never collide with a real, sequentially-assigned id.
|
|
27
|
+
pub const DICT_SHAPE: ShapeId = u32::MAX;
|
|
28
|
+
|
|
29
|
+
/// One node in the structure-transition tree: from this shape, adding a given key yields a child shape.
|
|
30
|
+
#[derive(Default)]
|
|
31
|
+
struct ShapeNode {
|
|
32
|
+
transitions: HashMap<Arc<str>, ShapeId>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
struct Registry {
|
|
36
|
+
nodes: Vec<ShapeNode>,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn registry() -> &'static RwLock<Registry> {
|
|
40
|
+
static REG: OnceLock<RwLock<Registry>> = OnceLock::new();
|
|
41
|
+
REG.get_or_init(|| {
|
|
42
|
+
RwLock::new(Registry {
|
|
43
|
+
// Index 0 == EMPTY_SHAPE.
|
|
44
|
+
nodes: vec![ShapeNode::default()],
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The shape reached by adding a **new** key `key` to an object currently of shape `from`.
|
|
50
|
+
///
|
|
51
|
+
/// Cached: the first object to take a given (shape, key) edge creates the child shape; every later
|
|
52
|
+
/// object with the same construction path reuses it. Cheap on the hot path (a read-lock + one hashmap
|
|
53
|
+
/// lookup once the edge exists). A `DICT_SHAPE` input (or shape-space exhaustion) stays `DICT_SHAPE`.
|
|
54
|
+
pub fn transition(from: ShapeId, key: &Arc<str>) -> ShapeId {
|
|
55
|
+
if from == DICT_SHAPE {
|
|
56
|
+
return DICT_SHAPE;
|
|
57
|
+
}
|
|
58
|
+
// Fast path: the edge already exists (the common case after the first object of this shape).
|
|
59
|
+
{
|
|
60
|
+
let reg = registry().read().unwrap();
|
|
61
|
+
match reg.nodes.get(from as usize) {
|
|
62
|
+
Some(node) => {
|
|
63
|
+
if let Some(&next) = node.transitions.get(key.as_ref()) {
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
None => return DICT_SHAPE, // out of range — should not happen; degrade safely
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Slow path: create the child shape and cache the edge.
|
|
71
|
+
let mut reg = registry().write().unwrap();
|
|
72
|
+
// Re-check under the write lock (another thread may have created it meanwhile).
|
|
73
|
+
if let Some(&next) = reg.nodes[from as usize].transitions.get(key.as_ref()) {
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
76
|
+
let new_id = reg.nodes.len();
|
|
77
|
+
if new_id >= DICT_SHAPE as usize {
|
|
78
|
+
return DICT_SHAPE; // ran out of shape ids — extremely unlikely; degrade to dictionary mode
|
|
79
|
+
}
|
|
80
|
+
reg.nodes.push(ShapeNode::default());
|
|
81
|
+
reg.nodes[from as usize]
|
|
82
|
+
.transitions
|
|
83
|
+
.insert(Arc::clone(key), new_id as ShapeId);
|
|
84
|
+
new_id as ShapeId
|
|
85
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//! URI encoding/decoding utilities.
|
|
2
|
+
|
|
3
|
+
/// Percent-decode a string (for decodeURI).
|
|
4
|
+
/// Does NOT decode reserved URI characters: ; / ? : @ & = + $ , #
|
|
5
|
+
/// These are characters that encodeURI does not encode, so decodeURI won't decode them.
|
|
6
|
+
pub fn percent_decode(input: &str) -> Result<String, String> {
|
|
7
|
+
// Reserved characters that decodeURI should NOT decode (because encodeURI doesn't encode them)
|
|
8
|
+
const RESERVED_ENCODED: &[&str] = &[
|
|
9
|
+
"%3B", "%3b", // ;
|
|
10
|
+
"%2F", "%2f", // /
|
|
11
|
+
"%3F", "%3f", // ?
|
|
12
|
+
"%3A", "%3a", // :
|
|
13
|
+
"%40", // @
|
|
14
|
+
"%26", // &
|
|
15
|
+
"%3D", "%3d", // =
|
|
16
|
+
"%2B", "%2b", // +
|
|
17
|
+
"%24", // $
|
|
18
|
+
"%2C", "%2c", // ,
|
|
19
|
+
"%23", // #
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
let mut result = String::with_capacity(input.len());
|
|
23
|
+
let mut chars = input.chars().peekable();
|
|
24
|
+
|
|
25
|
+
while let Some(c) = chars.next() {
|
|
26
|
+
if c == '%' {
|
|
27
|
+
// Peek at the next two characters to check if this is a reserved sequence
|
|
28
|
+
let mut hex = String::new();
|
|
29
|
+
let mut peek_chars = Vec::new();
|
|
30
|
+
for _ in 0..2 {
|
|
31
|
+
match chars.next() {
|
|
32
|
+
Some(h) if h.is_ascii_hexdigit() => {
|
|
33
|
+
hex.push(h);
|
|
34
|
+
peek_chars.push(h);
|
|
35
|
+
}
|
|
36
|
+
Some(h) => {
|
|
37
|
+
// Not a valid hex sequence, push as-is
|
|
38
|
+
result.push('%');
|
|
39
|
+
for pc in peek_chars {
|
|
40
|
+
result.push(pc);
|
|
41
|
+
}
|
|
42
|
+
result.push(h);
|
|
43
|
+
hex.clear();
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
None => return Err("URIError: malformed URI sequence".to_string()),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if hex.len() == 2 {
|
|
51
|
+
let encoded = format!("%{}", hex);
|
|
52
|
+
// Check if this is a reserved character that should NOT be decoded
|
|
53
|
+
if RESERVED_ENCODED
|
|
54
|
+
.iter()
|
|
55
|
+
.any(|r| r.eq_ignore_ascii_case(&encoded))
|
|
56
|
+
{
|
|
57
|
+
result.push_str(&encoded);
|
|
58
|
+
} else if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
|
59
|
+
result.push(byte as char);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
result.push(c);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Ok(result)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Percent-encode a string (for encodeURI).
|
|
71
|
+
/// Preserves: A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , #
|
|
72
|
+
pub fn percent_encode(input: &str) -> String {
|
|
73
|
+
const UNRESERVED: &[char] = &[
|
|
74
|
+
'-', '_', '.', '!', '~', '*', '\'', '(', ')', ';', '/', '?', ':', '@', '&', '=', '+', '$',
|
|
75
|
+
',', '#',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
let mut result = String::with_capacity(input.len());
|
|
79
|
+
for c in input.chars() {
|
|
80
|
+
if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
|
|
81
|
+
result.push(c);
|
|
82
|
+
} else {
|
|
83
|
+
for byte in c.to_string().as_bytes() {
|
|
84
|
+
result.push_str(&format!("%{:02X}", byte));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[cfg(test)]
|
|
92
|
+
mod tests {
|
|
93
|
+
use super::*;
|
|
94
|
+
|
|
95
|
+
fn percent_encode_component(input: &str) -> String {
|
|
96
|
+
const UNRESERVED: &[char] = &['-', '_', '.', '!', '~', '*', '\'', '(', ')'];
|
|
97
|
+
let mut result = String::new();
|
|
98
|
+
for c in input.chars() {
|
|
99
|
+
if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
|
|
100
|
+
result.push(c);
|
|
101
|
+
} else {
|
|
102
|
+
for byte in c.to_string().as_bytes() {
|
|
103
|
+
result.push_str(&format!("%{:02X}", byte));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[test]
|
|
111
|
+
fn test_encode_decode_roundtrip() {
|
|
112
|
+
let original = "hello world";
|
|
113
|
+
let encoded = percent_encode_component(original);
|
|
114
|
+
assert_eq!(encoded, "hello%20world");
|
|
115
|
+
let decoded = percent_decode(&encoded).unwrap();
|
|
116
|
+
assert_eq!(decoded, original);
|
|
117
|
+
}
|
|
118
|
+
}
|