1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum JsonKind {
7 Null,
8 Bool,
9 Number,
10 String,
11 Array,
12 Object,
13 Unknown,
14}
15
16pub fn looks_like_json(input: &str) -> bool {
18 detect_json_kind(input) != JsonKind::Unknown
19}
20
21pub fn looks_like_json_object(input: &str) -> bool {
23 let trimmed = input.trim();
24 trimmed.len() >= 2 && trimmed.starts_with('{') && trimmed.ends_with('}')
25}
26
27pub fn looks_like_json_array(input: &str) -> bool {
29 let trimmed = input.trim();
30 trimmed.len() >= 2 && trimmed.starts_with('[') && trimmed.ends_with(']')
31}
32
33pub fn detect_json_kind(input: &str) -> JsonKind {
35 let trimmed = input.trim();
36
37 if trimmed.is_empty() {
38 JsonKind::Unknown
39 } else if looks_like_json_object(trimmed) {
40 JsonKind::Object
41 } else if looks_like_json_array(trimmed) {
42 JsonKind::Array
43 } else if is_json_null(trimmed) {
44 JsonKind::Null
45 } else if is_json_bool(trimmed) {
46 JsonKind::Bool
47 } else if is_json_string(trimmed) {
48 JsonKind::String
49 } else if is_json_number(trimmed) {
50 JsonKind::Number
51 } else {
52 JsonKind::Unknown
53 }
54}
55
56pub fn is_json_null(input: &str) -> bool {
58 input.trim() == "null"
59}
60
61pub fn is_json_bool(input: &str) -> bool {
63 matches!(input.trim(), "true" | "false")
64}
65
66pub fn is_json_string(input: &str) -> bool {
68 unquote_json_string(input).is_some()
69}
70
71pub fn is_json_number(input: &str) -> bool {
73 let bytes = input.trim().as_bytes();
74
75 if bytes.is_empty() {
76 return false;
77 }
78
79 let mut index = 0;
80
81 if bytes[index] == b'-' {
82 index += 1;
83 }
84
85 if index >= bytes.len() {
86 return false;
87 }
88
89 if bytes[index] == b'0' {
90 index += 1;
91 } else if bytes[index].is_ascii_digit() {
92 while index < bytes.len() && bytes[index].is_ascii_digit() {
93 index += 1;
94 }
95 } else {
96 return false;
97 }
98
99 if index < bytes.len() && bytes[index].is_ascii_digit() && bytes[index - 1] == b'0' {
100 return false;
101 }
102
103 if index < bytes.len() && bytes[index] == b'.' {
104 index += 1;
105
106 let fraction_start = index;
107 while index < bytes.len() && bytes[index].is_ascii_digit() {
108 index += 1;
109 }
110
111 if fraction_start == index {
112 return false;
113 }
114 }
115
116 if index < bytes.len() && matches!(bytes[index], b'e' | b'E') {
117 index += 1;
118
119 if index < bytes.len() && matches!(bytes[index], b'+' | b'-') {
120 index += 1;
121 }
122
123 let exponent_start = index;
124 while index < bytes.len() && bytes[index].is_ascii_digit() {
125 index += 1;
126 }
127
128 if exponent_start == index {
129 return false;
130 }
131 }
132
133 index == bytes.len()
134}
135
136pub fn quote_json_string(input: &str) -> String {
138 format!("\"{}\"", escape_json_string(input))
139}
140
141pub fn unquote_json_string(input: &str) -> Option<String> {
143 let trimmed = input.trim();
144
145 if trimmed.len() < 2 || !trimmed.starts_with('"') || !trimmed.ends_with('"') {
146 return None;
147 }
148
149 let inner = &trimmed[1..trimmed.len() - 1];
150 let mut chars = inner.chars();
151 let mut output = String::new();
152
153 while let Some(ch) = chars.next() {
154 if ch == '"' || ch.is_control() {
155 return None;
156 }
157
158 if ch != '\\' {
159 output.push(ch);
160 continue;
161 }
162
163 let escaped = chars.next()?;
164 match escaped {
165 '"' => output.push('"'),
166 '\\' => output.push('\\'),
167 '/' => output.push('/'),
168 'b' => output.push('\u{0008}'),
169 'f' => output.push('\u{000C}'),
170 'n' => output.push('\n'),
171 'r' => output.push('\r'),
172 't' => output.push('\t'),
173 'u' => {
174 let mut hex = String::with_capacity(4);
175 for _ in 0..4 {
176 hex.push(chars.next()?);
177 }
178
179 let value = u32::from_str_radix(&hex, 16).ok()?;
180 output.push(char::from_u32(value)?);
181 },
182 _ => return None,
183 }
184 }
185
186 Some(output)
187}
188
189pub fn escape_json_string(input: &str) -> String {
191 let mut escaped = String::with_capacity(input.len());
192
193 for ch in input.chars() {
194 match ch {
195 '"' => escaped.push_str("\\\""),
196 '\\' => escaped.push_str("\\\\"),
197 '\u{0008}' => escaped.push_str("\\b"),
198 '\u{000C}' => escaped.push_str("\\f"),
199 '\n' => escaped.push_str("\\n"),
200 '\r' => escaped.push_str("\\r"),
201 '\t' => escaped.push_str("\\t"),
202 ch if ch.is_control() => {
203 escaped.push_str(&format!("\\u{:04X}", ch as u32));
204 },
205 _ => escaped.push(ch),
206 }
207 }
208
209 escaped
210}
211
212pub fn compact_json_basic(input: &str) -> String {
214 let mut compact = String::with_capacity(input.len());
215 let mut in_string = false;
216 let mut escaped = false;
217
218 for ch in input.chars() {
219 if in_string {
220 compact.push(ch);
221
222 if escaped {
223 escaped = false;
224 } else if ch == '\\' {
225 escaped = true;
226 } else if ch == '"' {
227 in_string = false;
228 }
229
230 continue;
231 }
232
233 if ch.is_whitespace() {
234 continue;
235 }
236
237 if ch == '"' {
238 in_string = true;
239 }
240
241 compact.push(ch);
242 }
243
244 compact
245}