Skip to main content

use_json/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A conservative classification of JSON input.
5#[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
16/// Returns `true` when the input looks like a supported JSON value.
17pub fn looks_like_json(input: &str) -> bool {
18    detect_json_kind(input) != JsonKind::Unknown
19}
20
21/// Returns `true` when the input looks like a JSON object.
22pub 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
27/// Returns `true` when the input looks like a JSON array.
28pub 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
33/// Detects the conservative JSON kind represented by the input.
34pub 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
56/// Returns `true` when the input is the `null` literal.
57pub fn is_json_null(input: &str) -> bool {
58    input.trim() == "null"
59}
60
61/// Returns `true` when the input is a JSON boolean literal.
62pub fn is_json_bool(input: &str) -> bool {
63    matches!(input.trim(), "true" | "false")
64}
65
66/// Returns `true` when the input is a quoted JSON string.
67pub fn is_json_string(input: &str) -> bool {
68    unquote_json_string(input).is_some()
69}
70
71/// Returns `true` when the input is a valid JSON number literal.
72pub 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
136/// Quotes a Rust string as a JSON string literal.
137pub fn quote_json_string(input: &str) -> String {
138    format!("\"{}\"", escape_json_string(input))
139}
140
141/// Unquotes a conservative JSON string literal.
142pub 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
189/// Escapes a Rust string for JSON string content.
190pub 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
212/// Removes whitespace outside strings without attempting full JSON parsing.
213pub 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}