@tishlang/tish 1.0.7 → 1.0.11

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.
Files changed (127) hide show
  1. package/Cargo.toml +43 -0
  2. package/LICENSE +13 -0
  3. package/README.md +66 -0
  4. package/crates/js_to_tish/Cargo.toml +9 -0
  5. package/crates/js_to_tish/README.md +18 -0
  6. package/crates/js_to_tish/src/error.rs +61 -0
  7. package/crates/js_to_tish/src/lib.rs +11 -0
  8. package/crates/js_to_tish/src/span_util.rs +35 -0
  9. package/crates/js_to_tish/src/transform/expr.rs +608 -0
  10. package/crates/js_to_tish/src/transform/stmt.rs +474 -0
  11. package/crates/js_to_tish/src/transform.rs +60 -0
  12. package/crates/tish/Cargo.toml +44 -0
  13. package/crates/tish/src/main.rs +585 -0
  14. package/crates/tish/src/repl_completion.rs +200 -0
  15. package/crates/tish/tests/integration_test.rs +726 -0
  16. package/crates/tish_ast/Cargo.toml +7 -0
  17. package/crates/tish_ast/src/ast.rs +494 -0
  18. package/crates/tish_ast/src/lib.rs +5 -0
  19. package/crates/tish_build_utils/Cargo.toml +5 -0
  20. package/crates/tish_build_utils/src/lib.rs +175 -0
  21. package/crates/tish_builtins/Cargo.toml +12 -0
  22. package/crates/tish_builtins/src/array.rs +410 -0
  23. package/crates/tish_builtins/src/globals.rs +197 -0
  24. package/crates/tish_builtins/src/helpers.rs +38 -0
  25. package/crates/tish_builtins/src/lib.rs +14 -0
  26. package/crates/tish_builtins/src/math.rs +80 -0
  27. package/crates/tish_builtins/src/object.rs +36 -0
  28. package/crates/tish_builtins/src/string.rs +253 -0
  29. package/crates/tish_bytecode/Cargo.toml +15 -0
  30. package/crates/tish_bytecode/src/chunk.rs +97 -0
  31. package/crates/tish_bytecode/src/compiler.rs +1361 -0
  32. package/crates/tish_bytecode/src/encoding.rs +100 -0
  33. package/crates/tish_bytecode/src/lib.rs +19 -0
  34. package/crates/tish_bytecode/src/opcode.rs +110 -0
  35. package/crates/tish_bytecode/src/peephole.rs +159 -0
  36. package/crates/tish_bytecode/src/serialize.rs +163 -0
  37. package/crates/tish_bytecode/tests/constant_folding.rs +84 -0
  38. package/crates/tish_bytecode/tests/shortcircuit.rs +49 -0
  39. package/crates/tish_bytecode/tests/sort_optimization.rs +31 -0
  40. package/crates/tish_compile/Cargo.toml +21 -0
  41. package/crates/tish_compile/src/codegen.rs +3316 -0
  42. package/crates/tish_compile/src/lib.rs +71 -0
  43. package/crates/tish_compile/src/resolve.rs +631 -0
  44. package/crates/tish_compile/src/types.rs +304 -0
  45. package/crates/tish_compile_js/Cargo.toml +16 -0
  46. package/crates/tish_compile_js/examples/jsx_vdom_smoke.tish +8 -0
  47. package/crates/tish_compile_js/src/codegen.rs +794 -0
  48. package/crates/tish_compile_js/src/error.rs +20 -0
  49. package/crates/tish_compile_js/src/js_intrinsics.rs +82 -0
  50. package/crates/tish_compile_js/src/lib.rs +27 -0
  51. package/crates/tish_compile_js/src/tests_jsx.rs +32 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +19 -0
  53. package/crates/tish_compiler_wasm/src/lib.rs +55 -0
  54. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +462 -0
  55. package/crates/tish_core/Cargo.toml +11 -0
  56. package/crates/tish_core/src/console_style.rs +128 -0
  57. package/crates/tish_core/src/json.rs +327 -0
  58. package/crates/tish_core/src/lib.rs +15 -0
  59. package/crates/tish_core/src/macros.rs +37 -0
  60. package/crates/tish_core/src/uri.rs +115 -0
  61. package/crates/tish_core/src/value.rs +376 -0
  62. package/crates/tish_cranelift/Cargo.toml +17 -0
  63. package/crates/tish_cranelift/src/lib.rs +41 -0
  64. package/crates/tish_cranelift/src/link.rs +120 -0
  65. package/crates/tish_cranelift/src/lower.rs +77 -0
  66. package/crates/tish_cranelift_runtime/Cargo.toml +19 -0
  67. package/crates/tish_cranelift_runtime/src/lib.rs +43 -0
  68. package/crates/tish_eval/Cargo.toml +26 -0
  69. package/crates/tish_eval/src/eval.rs +3205 -0
  70. package/crates/tish_eval/src/http.rs +122 -0
  71. package/crates/tish_eval/src/lib.rs +59 -0
  72. package/crates/tish_eval/src/natives.rs +301 -0
  73. package/crates/tish_eval/src/promise.rs +173 -0
  74. package/crates/tish_eval/src/regex.rs +298 -0
  75. package/crates/tish_eval/src/timers.rs +111 -0
  76. package/crates/tish_eval/src/value.rs +224 -0
  77. package/crates/tish_eval/src/value_convert.rs +85 -0
  78. package/crates/tish_fmt/Cargo.toml +16 -0
  79. package/crates/tish_fmt/src/bin/tish-fmt.rs +41 -0
  80. package/crates/tish_fmt/src/lib.rs +884 -0
  81. package/crates/tish_jsx_web/Cargo.toml +7 -0
  82. package/crates/tish_jsx_web/README.md +18 -0
  83. package/crates/tish_jsx_web/src/lib.rs +157 -0
  84. package/crates/tish_jsx_web/vendor/Lattish.tish +347 -0
  85. package/crates/tish_lexer/Cargo.toml +7 -0
  86. package/crates/tish_lexer/src/lib.rs +430 -0
  87. package/crates/tish_lexer/src/token.rs +155 -0
  88. package/crates/tish_lint/Cargo.toml +17 -0
  89. package/crates/tish_lint/src/bin/tish-lint.rs +77 -0
  90. package/crates/tish_lint/src/lib.rs +278 -0
  91. package/crates/tish_llvm/Cargo.toml +11 -0
  92. package/crates/tish_llvm/src/lib.rs +106 -0
  93. package/crates/tish_lsp/Cargo.toml +22 -0
  94. package/crates/tish_lsp/README.md +26 -0
  95. package/crates/tish_lsp/src/main.rs +615 -0
  96. package/crates/tish_native/Cargo.toml +14 -0
  97. package/crates/tish_native/src/build.rs +102 -0
  98. package/crates/tish_native/src/lib.rs +237 -0
  99. package/crates/tish_opt/Cargo.toml +11 -0
  100. package/crates/tish_opt/src/lib.rs +896 -0
  101. package/crates/tish_parser/Cargo.toml +9 -0
  102. package/crates/tish_parser/src/lib.rs +123 -0
  103. package/crates/tish_parser/src/parser.rs +1714 -0
  104. package/crates/tish_runtime/Cargo.toml +26 -0
  105. package/crates/tish_runtime/src/http.rs +308 -0
  106. package/crates/tish_runtime/src/http_fetch.rs +453 -0
  107. package/crates/tish_runtime/src/lib.rs +1004 -0
  108. package/crates/tish_runtime/src/native_promise.rs +26 -0
  109. package/crates/tish_runtime/src/promise.rs +77 -0
  110. package/crates/tish_runtime/src/promise_io.rs +41 -0
  111. package/crates/tish_runtime/src/timers.rs +125 -0
  112. package/crates/tish_runtime/src/ws.rs +725 -0
  113. package/crates/tish_runtime/tests/fetch_readable_stream.rs +99 -0
  114. package/crates/tish_vm/Cargo.toml +31 -0
  115. package/crates/tish_vm/src/lib.rs +39 -0
  116. package/crates/tish_vm/src/vm.rs +1399 -0
  117. package/crates/tish_wasm/Cargo.toml +13 -0
  118. package/crates/tish_wasm/src/lib.rs +358 -0
  119. package/crates/tish_wasm_runtime/Cargo.toml +25 -0
  120. package/crates/tish_wasm_runtime/src/lib.rs +36 -0
  121. package/justfile +260 -0
  122. package/package.json +8 -3
  123. package/platform/darwin-arm64/tish +0 -0
  124. package/platform/darwin-x64/tish +0 -0
  125. package/platform/linux-arm64/tish +0 -0
  126. package/platform/linux-x64/tish +0 -0
  127. package/platform/win32-x64/tish.exe +0 -0
@@ -0,0 +1,327 @@
1
+ //! JSON parsing and stringification for Tish values.
2
+
3
+ use crate::Value;
4
+ use std::cell::RefCell;
5
+ use std::collections::HashMap;
6
+ use std::rc::Rc;
7
+ use std::sync::Arc;
8
+
9
+ /// Parse JSON string into a Value.
10
+ pub fn json_parse(json: &str) -> Result<Value, String> {
11
+ let json = json.trim();
12
+ if json.is_empty() {
13
+ return Err("SyntaxError: Unexpected end of JSON input".to_string());
14
+ }
15
+ let (value, rest) = parse_value(json)?;
16
+ if !rest.trim().is_empty() {
17
+ return Err("SyntaxError: Unexpected token at end of JSON".to_string());
18
+ }
19
+ Ok(value)
20
+ }
21
+
22
+ /// Stringify a Value to JSON.
23
+ pub fn json_stringify(value: &Value) -> String {
24
+ match value {
25
+ Value::Null => "null".to_string(),
26
+ Value::Bool(b) => b.to_string(),
27
+ Value::Number(n) => {
28
+ if n.is_nan() || n.is_infinite() {
29
+ "null".to_string()
30
+ } else {
31
+ n.to_string()
32
+ }
33
+ }
34
+ Value::String(s) => format!("\"{}\"", escape_json_string(s)),
35
+ Value::Array(arr) => {
36
+ let borrowed = arr.borrow();
37
+ let mut result = String::with_capacity(borrowed.len() * 8 + 2);
38
+ result.push('[');
39
+ let mut first = true;
40
+ for item in borrowed.iter() {
41
+ if !first {
42
+ result.push(',');
43
+ }
44
+ first = false;
45
+ result.push_str(&json_stringify(item));
46
+ }
47
+ result.push(']');
48
+ result
49
+ }
50
+ Value::Object(obj) => {
51
+ let borrowed = obj.borrow();
52
+ let mut keys: Vec<_> = borrowed.keys().collect();
53
+ keys.sort();
54
+ let mut result = String::with_capacity(borrowed.len() * 16 + 2);
55
+ result.push('{');
56
+ let mut first = true;
57
+ for key in keys {
58
+ if !first {
59
+ result.push(',');
60
+ }
61
+ first = false;
62
+ result.push('"');
63
+ result.push_str(&escape_json_string(key));
64
+ result.push_str("\":");
65
+ result.push_str(&json_stringify(borrowed.get(key).unwrap()));
66
+ }
67
+ result.push('}');
68
+ result
69
+ }
70
+ Value::Function(_) => "null".to_string(),
71
+ Value::Promise(_) => "null".to_string(),
72
+ Value::Opaque(_) => "null".to_string(),
73
+ #[cfg(feature = "regex")]
74
+ Value::RegExp(_) => "null".to_string(),
75
+ }
76
+ }
77
+
78
+ fn escape_json_string(s: &str) -> String {
79
+ let mut result = String::with_capacity(s.len());
80
+ for c in s.chars() {
81
+ match c {
82
+ '"' => result.push_str("\\\""),
83
+ '\\' => result.push_str("\\\\"),
84
+ '\n' => result.push_str("\\n"),
85
+ '\r' => result.push_str("\\r"),
86
+ '\t' => result.push_str("\\t"),
87
+ c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
88
+ c => result.push(c),
89
+ }
90
+ }
91
+ result
92
+ }
93
+
94
+ fn parse_value(input: &str) -> Result<(Value, &str), String> {
95
+ let input = input.trim_start();
96
+ if input.is_empty() {
97
+ return Err("Unexpected end of JSON input".to_string());
98
+ }
99
+
100
+ match input.chars().next().unwrap() {
101
+ 'n' => parse_null(input),
102
+ 't' | 'f' => parse_bool(input),
103
+ '"' => parse_string(input),
104
+ '[' => parse_array(input),
105
+ '{' => parse_object(input),
106
+ c if c == '-' || c.is_ascii_digit() => parse_number(input),
107
+ c => Err(format!("Unexpected character '{}' in JSON", c)),
108
+ }
109
+ }
110
+
111
+ fn parse_null(input: &str) -> Result<(Value, &str), String> {
112
+ if let Some(rest) = input.strip_prefix("null") {
113
+ Ok((Value::Null, rest))
114
+ } else {
115
+ Err("Expected 'null'".to_string())
116
+ }
117
+ }
118
+
119
+ fn parse_bool(input: &str) -> Result<(Value, &str), String> {
120
+ if let Some(rest) = input.strip_prefix("true") {
121
+ Ok((Value::Bool(true), rest))
122
+ } else if let Some(rest) = input.strip_prefix("false") {
123
+ Ok((Value::Bool(false), rest))
124
+ } else {
125
+ Err("Expected 'true' or 'false'".to_string())
126
+ }
127
+ }
128
+
129
+ fn parse_string(input: &str) -> Result<(Value, &str), String> {
130
+ let input = &input[1..]; // skip opening quote
131
+ let mut result = String::new();
132
+ let mut chars = input.chars().peekable();
133
+ let mut byte_count = 0;
134
+
135
+ loop {
136
+ match chars.next() {
137
+ None => return Err("Unterminated string".to_string()),
138
+ Some('"') => {
139
+ byte_count += 1;
140
+ break;
141
+ }
142
+ Some('\\') => {
143
+ byte_count += 1;
144
+ match chars.next() {
145
+ Some('n') => {
146
+ result.push('\n');
147
+ byte_count += 1;
148
+ }
149
+ Some('r') => {
150
+ result.push('\r');
151
+ byte_count += 1;
152
+ }
153
+ Some('t') => {
154
+ result.push('\t');
155
+ byte_count += 1;
156
+ }
157
+ Some('\\') => {
158
+ result.push('\\');
159
+ byte_count += 1;
160
+ }
161
+ Some('"') => {
162
+ result.push('"');
163
+ byte_count += 1;
164
+ }
165
+ Some('/') => {
166
+ result.push('/');
167
+ byte_count += 1;
168
+ }
169
+ Some('u') => {
170
+ byte_count += 1;
171
+ let mut hex = String::new();
172
+ for _ in 0..4 {
173
+ if let Some(c) = chars.next() {
174
+ hex.push(c);
175
+ byte_count += c.len_utf8();
176
+ }
177
+ }
178
+ if let Ok(n) = u32::from_str_radix(&hex, 16) {
179
+ if let Some(c) = char::from_u32(n) {
180
+ result.push(c);
181
+ }
182
+ }
183
+ }
184
+ Some(c) => {
185
+ result.push(c);
186
+ byte_count += c.len_utf8();
187
+ }
188
+ None => return Err("Unterminated escape sequence".to_string()),
189
+ }
190
+ }
191
+ Some(c) => {
192
+ result.push(c);
193
+ byte_count += c.len_utf8();
194
+ }
195
+ }
196
+ }
197
+
198
+ Ok((Value::String(result.into()), &input[byte_count..]))
199
+ }
200
+
201
+ fn parse_number(input: &str) -> Result<(Value, &str), String> {
202
+ let mut end = 0;
203
+ let chars: Vec<char> = input.chars().collect();
204
+
205
+ if chars.get(end) == Some(&'-') {
206
+ end += 1;
207
+ }
208
+
209
+ while end < chars.len() && chars[end].is_ascii_digit() {
210
+ end += 1;
211
+ }
212
+
213
+ if chars.get(end) == Some(&'.') {
214
+ end += 1;
215
+ while end < chars.len() && chars[end].is_ascii_digit() {
216
+ end += 1;
217
+ }
218
+ }
219
+
220
+ if chars.get(end) == Some(&'e') || chars.get(end) == Some(&'E') {
221
+ end += 1;
222
+ if chars.get(end) == Some(&'+') || chars.get(end) == Some(&'-') {
223
+ end += 1;
224
+ }
225
+ while end < chars.len() && chars[end].is_ascii_digit() {
226
+ end += 1;
227
+ }
228
+ }
229
+
230
+ let num_str: String = chars[..end].iter().collect();
231
+ let byte_len: usize = chars[..end].iter().map(|c| c.len_utf8()).sum();
232
+
233
+ num_str
234
+ .parse::<f64>()
235
+ .map(|n| (Value::Number(n), &input[byte_len..]))
236
+ .map_err(|_| format!("Invalid number: {}", num_str))
237
+ }
238
+
239
+ fn parse_array(input: &str) -> Result<(Value, &str), String> {
240
+ let mut input = &input[1..]; // skip '['
241
+ let mut items = Vec::new();
242
+
243
+ input = input.trim_start();
244
+ if let Some(rest) = input.strip_prefix(']') {
245
+ return Ok((Value::Array(Rc::new(RefCell::new(items))), rest));
246
+ }
247
+
248
+ loop {
249
+ let (value, rest) = parse_value(input)?;
250
+ items.push(value);
251
+ input = rest.trim_start();
252
+
253
+ match input.chars().next() {
254
+ Some(',') => input = &input[1..],
255
+ Some(']') => return Ok((Value::Array(Rc::new(RefCell::new(items))), &input[1..])),
256
+ _ => return Err("Expected ',' or ']' in array".to_string()),
257
+ }
258
+ }
259
+ }
260
+
261
+ fn parse_object(input: &str) -> Result<(Value, &str), String> {
262
+ let mut input = &input[1..]; // skip '{'
263
+ let mut map = HashMap::new();
264
+
265
+ input = input.trim_start();
266
+ if let Some(rest) = input.strip_prefix('}') {
267
+ return Ok((Value::Object(Rc::new(RefCell::new(map))), rest));
268
+ }
269
+
270
+ loop {
271
+ input = input.trim_start();
272
+ if !input.starts_with('"') {
273
+ return Err("Expected string key in object".to_string());
274
+ }
275
+
276
+ let (key_val, rest) = parse_string(input)?;
277
+ let key: Arc<str> = match key_val {
278
+ Value::String(s) => s,
279
+ _ => unreachable!(),
280
+ };
281
+
282
+ input = rest.trim_start();
283
+ if !input.starts_with(':') {
284
+ return Err("Expected ':' after key in object".to_string());
285
+ }
286
+ input = &input[1..];
287
+
288
+ let (value, rest) = parse_value(input)?;
289
+ map.insert(key, value);
290
+ input = rest.trim_start();
291
+
292
+ match input.chars().next() {
293
+ Some(',') => input = &input[1..],
294
+ Some('}') => return Ok((Value::Object(Rc::new(RefCell::new(map))), &input[1..])),
295
+ _ => return Err("Expected ',' or '}' in object".to_string()),
296
+ }
297
+ }
298
+ }
299
+
300
+ #[cfg(test)]
301
+ mod tests {
302
+ use super::*;
303
+
304
+ #[test]
305
+ fn test_parse_primitives() {
306
+ assert!(matches!(json_parse("null").unwrap(), Value::Null));
307
+ assert!(matches!(json_parse("true").unwrap(), Value::Bool(true)));
308
+ assert!(matches!(json_parse("false").unwrap(), Value::Bool(false)));
309
+ assert!(matches!(json_parse("42").unwrap(), Value::Number(n) if n == 42.0));
310
+ assert!(matches!(json_parse("\"hello\"").unwrap(), Value::String(s) if s.as_ref() == "hello"));
311
+ }
312
+
313
+ #[test]
314
+ fn test_roundtrip() {
315
+ let original = "{\"name\":\"test\",\"count\":42}";
316
+ let value = json_parse(original).unwrap();
317
+ let stringified = json_stringify(&value);
318
+ let reparsed = json_parse(&stringified).unwrap();
319
+
320
+ match (&value, &reparsed) {
321
+ (Value::Object(a), Value::Object(b)) => {
322
+ assert_eq!(a.borrow().len(), b.borrow().len());
323
+ }
324
+ _ => panic!("Expected objects"),
325
+ }
326
+ }
327
+ }
@@ -0,0 +1,15 @@
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 (tish_eval) and compiled runtime (tish_runtime).
5
+
6
+ mod console_style;
7
+ mod json;
8
+ mod macros;
9
+ mod uri;
10
+ mod value;
11
+
12
+ pub use console_style::{format_value_styled, format_values_for_console, use_console_colors};
13
+ pub use value::*;
14
+ pub use json::{json_parse, json_stringify};
15
+ pub use uri::{percent_decode, percent_encode};
@@ -0,0 +1,37 @@
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 tish_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::cell::RefCell;
27
+ use std::collections::HashMap;
28
+ use std::rc::Rc;
29
+ use std::sync::Arc;
30
+ use $crate::Value;
31
+ let mut map = HashMap::new();
32
+ $(
33
+ map.insert(Arc::from($name), Value::Function(Rc::new($fn)));
34
+ )*
35
+ Value::Object(Rc::new(RefCell::new(map)))
36
+ }};
37
+ }
@@ -0,0 +1,115 @@
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.iter().any(|r| r.eq_ignore_ascii_case(&encoded)) {
54
+ result.push_str(&encoded);
55
+ } else if let Ok(byte) = u8::from_str_radix(&hex, 16) {
56
+ result.push(byte as char);
57
+ }
58
+ }
59
+ } else {
60
+ result.push(c);
61
+ }
62
+ }
63
+
64
+ Ok(result)
65
+ }
66
+
67
+ /// Percent-encode a string (for encodeURI).
68
+ /// Preserves: A-Z a-z 0-9 - _ . ! ~ * ' ( ) ; / ? : @ & = + $ , #
69
+ pub fn percent_encode(input: &str) -> String {
70
+ const UNRESERVED: &[char] = &[
71
+ '-', '_', '.', '!', '~', '*', '\'', '(', ')', ';', '/', '?', ':', '@', '&', '=', '+', '$',
72
+ ',', '#',
73
+ ];
74
+
75
+ let mut result = String::with_capacity(input.len());
76
+ for c in input.chars() {
77
+ if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
78
+ result.push(c);
79
+ } else {
80
+ for byte in c.to_string().as_bytes() {
81
+ result.push_str(&format!("%{:02X}", byte));
82
+ }
83
+ }
84
+ }
85
+ result
86
+ }
87
+
88
+ #[cfg(test)]
89
+ mod tests {
90
+ use super::*;
91
+
92
+ fn percent_encode_component(input: &str) -> String {
93
+ const UNRESERVED: &[char] = &['-', '_', '.', '!', '~', '*', '\'', '(', ')'];
94
+ let mut result = String::new();
95
+ for c in input.chars() {
96
+ if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
97
+ result.push(c);
98
+ } else {
99
+ for byte in c.to_string().as_bytes() {
100
+ result.push_str(&format!("%{:02X}", byte));
101
+ }
102
+ }
103
+ }
104
+ result
105
+ }
106
+
107
+ #[test]
108
+ fn test_encode_decode_roundtrip() {
109
+ let original = "hello world";
110
+ let encoded = percent_encode_component(original);
111
+ assert_eq!(encoded, "hello%20world");
112
+ let decoded = percent_decode(&encoded).unwrap();
113
+ assert_eq!(decoded, original);
114
+ }
115
+ }