Skip to main content

eui_core/host/
sources.rs

1//! Binding sources. A data model path can follow a host source
2//! (`exp:issues?board=…`): the host registers one resolver per scheme, the
3//! router emits a `bind` op, the resolver's emits land at the path.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8
9use super::js_trim;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ParsedSource {
13    /// The source as written.
14    pub uri: String,
15    pub scheme: String,
16    pub name: String,
17    /// Keys sorted; the last value of a repeated key wins.
18    pub params: BTreeMap<String, String>,
19}
20
21/// JavaScript's `decodeURIComponent(part.replace(/\+/g, " "))`, the RAW part
22/// when it throws (a bad escape, invalid UTF-8).
23fn decode(part: &str) -> String {
24    fn hex(b: u8) -> Option<u8> {
25        (b as char).to_digit(16).map(|d| d as u8)
26    }
27    let spaced = part.replace('+', " ");
28    let bytes = spaced.as_bytes();
29    let mut out = Vec::with_capacity(bytes.len());
30    let mut i = 0;
31    while i < bytes.len() {
32        if bytes[i] == b'%' {
33            match (bytes.get(i + 1).copied().and_then(hex), bytes.get(i + 2).copied().and_then(hex)) {
34                (Some(h), Some(l)) => {
35                    out.push(h * 16 + l);
36                    i += 3;
37                }
38                _ => return part.to_string(),
39            }
40        } else {
41            out.push(bytes[i]);
42            i += 1;
43        }
44    }
45    String::from_utf8(out).unwrap_or_else(|_| part.to_string())
46}
47
48fn is_line_terminator(c: char) -> bool {
49    matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
50}
51
52/// `exp:issues?board=b1&limit=20` → `{scheme: "exp", name: "issues",
53/// params: {board: "b1", limit: "20"}}`; `None` when it does not parse
54/// (`catalog/host.json` sources.pattern).
55pub fn parse_source(uri: &str) -> Option<ParsedSource> {
56    let text = js_trim(uri);
57    let (scheme, rest) = text.split_once(':')?;
58    let mut chars = scheme.chars();
59    if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) {
60        return None;
61    }
62    let (raw_name, query) = match rest.split_once('?') {
63        Some((n, q)) => (n, Some(q)),
64        None => (rest, None),
65    };
66    if raw_name.is_empty() || query.is_some_and(|q| q.chars().any(is_line_terminator)) {
67        return None;
68    }
69    let name = decode(raw_name);
70    if name.is_empty() {
71        return None;
72    }
73    let mut params = BTreeMap::new();
74    for pair in query.unwrap_or("").split('&') {
75        if pair.is_empty() {
76            continue;
77        }
78        let (k, v) = match pair.find('=') {
79            Some(eq) => (&pair[..eq], Some(&pair[eq + 1..])),
80            None => (pair, None),
81        };
82        let key = decode(k);
83        if key.is_empty() {
84            continue;
85        }
86        params.insert(key, v.map(decode).unwrap_or_default());
87    }
88    Some(ParsedSource { uri: uri.to_string(), scheme: scheme.to_lowercase(), name, params })
89}
90
91/// JavaScript's `encodeURIComponent`: every UTF-8 byte but
92/// `A-Z a-z 0-9 - _ . ! ~ * ' ( )` as `%XX` (upper-case hex).
93pub(crate) fn encode_component(s: &str) -> String {
94    let mut out = String::with_capacity(s.len());
95    for b in s.bytes() {
96        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')') {
97            out.push(b as char);
98        } else {
99            out.push_str(&format!("%{b:02X}"));
100        }
101    }
102    out
103}
104
105/// VAPP-122: `source` with `params` appended as its query, for the `bind`
106/// action (`sourceWithParams`): keys sorted, key and value percent-encoded,
107/// a null value skipped, an object or array as JSON, after `?` or `&`. A
108/// repeated key parses with the last value, so a param overrides the
109/// source's own.
110pub fn source_with_params(source: &str, params: Option<&serde_json::Value>) -> String {
111    let Some(params) = params.and_then(serde_json::Value::as_object) else { return source.to_string() };
112    let mut keys: Vec<&String> = params.keys().collect();
113    keys.sort();
114    let mut pairs = Vec::new();
115    for key in keys {
116        let v = &params[key];
117        if v.is_null() {
118            continue;
119        }
120        let text = match v {
121            serde_json::Value::String(s) => s.clone(),
122            serde_json::Value::Object(_) | serde_json::Value::Array(_) => v.to_string(),
123            other => crate::json::to_js_string(other),
124        };
125        pairs.push(format!("{}={}", encode_component(key), encode_component(&text)));
126    }
127    if pairs.is_empty() {
128        return source.to_string();
129    }
130    format!("{source}{}{}", if source.contains('?') { '&' } else { '?' }, pairs.join("&"))
131}