Skip to main content

polydat_nodes/
encoding.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! String encoding and decoding nodes: HTML entities, URL percent-encoding.
5
6#[cfg(test)]
7use polydat::ast::Value;
8
9// =================================================================
10// HTML entity encoding
11// =================================================================
12
13/// Encode HTML special characters as entities (`& < > " '`).
14#[polydat::polydat_node(category = Encoding)]
15fn html_encode(input: String) -> String {
16    let mut result = String::with_capacity(input.len());
17    for c in input.chars() {
18        match c {
19            '&' => result.push_str("&amp;"),
20            '<' => result.push_str("&lt;"),
21            '>' => result.push_str("&gt;"),
22            '"' => result.push_str("&quot;"),
23            '\'' => result.push_str("&#x27;"),
24            _ => result.push(c),
25        }
26    }
27    result
28}
29
30/// Decode HTML entities back to characters.
31#[polydat::polydat_node(category = Encoding)]
32fn html_decode(input: String) -> String {
33    input
34        .replace("&amp;", "&")
35        .replace("&lt;", "<")
36        .replace("&gt;", ">")
37        .replace("&quot;", "\"")
38        .replace("&#x27;", "'")
39        .replace("&#39;", "'")
40}
41
42fn is_url_unreserved(b: u8) -> bool {
43    b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
44}
45
46/// Percent-encode a string for use in URLs (RFC 3986).
47#[polydat::polydat_node(category = Encoding)]
48fn url_encode(input: String) -> String {
49    let mut result = String::with_capacity(input.len());
50    for &b in input.as_bytes() {
51        if is_url_unreserved(b) {
52            result.push(b as char);
53        } else {
54            result.push_str(&format!("%{b:02X}"));
55        }
56    }
57    result
58}
59
60/// Decode a percent-encoded URL string.
61#[polydat::polydat_node(category = Encoding)]
62fn url_decode(input: String) -> String {
63    let bytes = input.as_bytes();
64    let mut result = Vec::with_capacity(bytes.len());
65    let mut i = 0;
66    while i < bytes.len() {
67        if bytes[i] == b'%'
68            && i + 2 < bytes.len()
69            && let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16)
70        {
71            result.push(byte);
72            i += 3;
73            continue;
74        }
75        result.push(bytes[i]);
76        i += 1;
77    }
78    String::from_utf8_lossy(&result).into_owned()
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use polydat::ast::PolydatNode;
85
86    #[test]
87    fn html_encode_basic() {
88        let node = HtmlEncode::new();
89        let mut out = [Value::None];
90        node.eval(&[Value::Str("<b>hello & world</b>".into())], &mut out);
91        assert_eq!(out[0].as_str(), "&lt;b&gt;hello &amp; world&lt;/b&gt;");
92    }
93
94    #[test]
95    fn html_encode_quotes() {
96        let node = HtmlEncode::new();
97        let mut out = [Value::None];
98        node.eval(&[Value::Str(r#"say "hello" it's fine"#.into())], &mut out);
99        assert_eq!(out[0].as_str(), "say &quot;hello&quot; it&#x27;s fine");
100    }
101
102    #[test]
103    fn html_encode_passthrough() {
104        let node = HtmlEncode::new();
105        let mut out = [Value::None];
106        node.eval(&[Value::Str("plain text 123".into())], &mut out);
107        assert_eq!(out[0].as_str(), "plain text 123");
108    }
109
110    #[test]
111    fn html_roundtrip() {
112        let enc = HtmlEncode::new();
113        let dec = HtmlDecode::new();
114        let mut mid = [Value::None];
115        let mut out = [Value::None];
116        let input = "<div class=\"test\">hello & 'world'</div>";
117        enc.eval(&[Value::Str(input.into())], &mut mid);
118        dec.eval(&[mid[0].clone()], &mut out);
119        assert_eq!(out[0].as_str(), input);
120    }
121
122    #[test]
123    fn url_encode_basic() {
124        let node = UrlEncode::new();
125        let mut out = [Value::None];
126        node.eval(&[Value::Str("hello world".into())], &mut out);
127        assert_eq!(out[0].as_str(), "hello%20world");
128    }
129
130    #[test]
131    fn url_encode_special() {
132        let node = UrlEncode::new();
133        let mut out = [Value::None];
134        node.eval(&[Value::Str("a=1&b=2".into())], &mut out);
135        assert_eq!(out[0].as_str(), "a%3D1%26b%3D2");
136    }
137
138    #[test]
139    fn url_encode_passthrough() {
140        let node = UrlEncode::new();
141        let mut out = [Value::None];
142        node.eval(&[Value::Str("hello-world_123.txt~".into())], &mut out);
143        assert_eq!(out[0].as_str(), "hello-world_123.txt~");
144    }
145
146    #[test]
147    fn url_roundtrip() {
148        let enc = UrlEncode::new();
149        let dec = UrlDecode::new();
150        let mut mid = [Value::None];
151        let mut out = [Value::None];
152        let input = "hello world & friends = cool";
153        enc.eval(&[Value::Str(input.into())], &mut mid);
154        dec.eval(&[mid[0].clone()], &mut out);
155        assert_eq!(out[0].as_str(), input);
156    }
157}