Skip to main content

ciplink_server/
codegen.rs

1//! The `types` subcommand: a TypeScript module typing the tags of a running server (`GET /tags`), as a typed tag
2//! tree (`tagTree<PLC1Tags>`) or the 0.2 union of tag names.
3
4use std::io::{Read, Write};
5use std::net::TcpStream;
6use std::time::Duration;
7
8use indexmap::IndexMap;
9use regex::Regex;
10use serde_json::{Map, Value as Json};
11
12use crate::rules::{Resolver, truthy};
13
14/// Plain integers, typed `IntRef` (a number with `.bit(n)`). Unlike ciplink-tagserver, BYTE, WORD and LWORD are
15/// not here: ciplink reads every bit string as a list of bools, so they are typed like DWORD (see `BIT_STRINGS`).
16const INTS: [&str; 8] = [
17    "SINT", "INT", "DINT", "LINT", "USINT", "UINT", "UDINT", "ULINT",
18];
19/// Bit strings: values are lists of bools (least significant first) and `X.n` is a bit.
20const BIT_STRINGS: [&str; 4] = ["BYTE", "WORD", "DWORD", "LWORD"];
21/// Names the tree proxy answers itself (amendment 31).
22const RESERVED: [&str; 5] = ["bit", "length", "then", "toString", "toJSON"];
23
24/// GET `<url>/tags[?plc=NAME]` with a Bearer token; the whole body (plc, version, tags, types, rules, groups,
25/// decorators, udt, tag_settings). `http://` only; a non-200 status is an error with the status line and body.
26pub fn fetch_tags(url: &str, token: &str, plc: Option<&str>) -> Result<Json, String> {
27    let rest = url
28        .strip_prefix("http://")
29        .ok_or_else(|| format!("{url}: only http:// URLs are supported"))?;
30    let (authority, base) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
31    if authority.is_empty() {
32        return Err(format!("{url}: no host"));
33    }
34    let has_port = !authority.ends_with(']')
35        && authority
36            .rsplit_once(':')
37            .is_some_and(|(_, p)| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
38    let addr = if has_port {
39        authority.to_owned()
40    } else {
41        format!("{authority}:80")
42    };
43    let query = match plc {
44        Some(plc) => format!("/tags?plc={}", quote(plc)),
45        None => "/tags".to_owned(),
46    };
47    let path = format!("{}{query}", base.trim_end_matches('/'));
48    let err = |e: std::io::Error| format!("{url}: {e}");
49    let mut stream = TcpStream::connect(&addr).map_err(err)?;
50    stream
51        .set_read_timeout(Some(Duration::from_secs(60)))
52        .map_err(err)?;
53    let request = format!(
54        "GET {path} HTTP/1.1\r\nHost: {authority}\r\nAuthorization: Bearer {token}\r\n\
55         Accept: application/json\r\nConnection: close\r\n\r\n"
56    );
57    stream.write_all(request.as_bytes()).map_err(err)?;
58    let mut raw = Vec::new();
59    stream.read_to_end(&mut raw).map_err(err)?;
60    let (status, body) = parse_response(&raw).map_err(|e| format!("{url}: {e}"))?;
61    let code = status.split_whitespace().nth(1);
62    if code != Some("200") {
63        return Err(format!(
64            "{url}{path}: {status}: {}",
65            String::from_utf8_lossy(&body)
66        ));
67    }
68    serde_json::from_slice(&body).map_err(|e| format!("{url}{path}: {e}"))
69}
70
71/// Percent-encodes like Python's `urllib.parse.quote` (unreserved characters and `/` kept).
72fn quote(s: &str) -> String {
73    let mut out = String::with_capacity(s.len());
74    for b in s.bytes() {
75        if b.is_ascii_alphanumeric() || b"_.-~/".contains(&b) {
76            out.push(char::from(b));
77        } else {
78            out.push_str(&format!("%{b:02X}"));
79        }
80    }
81    out
82}
83
84/// (status line, body) of a whole HTTP/1.1 response: Content-Length, chunked, or up to the end.
85fn parse_response(raw: &[u8]) -> Result<(String, Vec<u8>), String> {
86    let end = raw
87        .windows(4)
88        .position(|w| w == b"\r\n\r\n")
89        .ok_or("incomplete HTTP response")?;
90    let head = String::from_utf8_lossy(&raw[..end]);
91    let rest = &raw[end + 4..];
92    let mut lines = head.split("\r\n");
93    let status = lines.next().unwrap_or_default().to_owned();
94    let mut length = None;
95    let mut chunked = false;
96    for line in lines {
97        let Some((name, value)) = line.split_once(':') else {
98            continue;
99        };
100        let value = value.trim();
101        if name.eq_ignore_ascii_case("content-length") {
102            length = Some(
103                value
104                    .parse::<usize>()
105                    .map_err(|_| format!("bad Content-Length {value}"))?,
106            );
107        } else if name.eq_ignore_ascii_case("transfer-encoding") {
108            chunked = value.to_ascii_lowercase().contains("chunked");
109        }
110    }
111    let body = if chunked {
112        dechunk(rest)?
113    } else if let Some(n) = length {
114        rest.get(..n)
115            .ok_or("HTTP body shorter than its Content-Length")?
116            .to_vec()
117    } else {
118        rest.to_vec()
119    };
120    Ok((status, body))
121}
122
123fn dechunk(mut data: &[u8]) -> Result<Vec<u8>, String> {
124    let mut body = Vec::new();
125    loop {
126        let eol = data
127            .windows(2)
128            .position(|w| w == b"\r\n")
129            .ok_or("truncated chunked body")?;
130        let line = String::from_utf8_lossy(&data[..eol]);
131        let size = line.split(';').next().unwrap_or_default().trim();
132        let size =
133            usize::from_str_radix(size, 16).map_err(|_| format!("bad chunk size {size:?}"))?;
134        data = &data[eol + 2..];
135        if size == 0 {
136            return Ok(body); // trailers ignored
137        }
138        body.extend_from_slice(data.get(..size).ok_or("truncated chunk")?);
139        data = data.get(size + 2..).ok_or("truncated chunk")?;
140    }
141}
142
143/// Readable names: each tag, but for an AOI its readable members (Inst.Member) instead of the instance.
144pub fn tag_names(tags: &[Json]) -> Vec<String> {
145    let mut names = Vec::new();
146    for tag in tags {
147        let name = py_str(tag.get("name").unwrap_or(&Json::Null));
148        match tag.get("aoi_readable") {
149            Some(members) => {
150                let members = members.as_array().map(Vec::as_slice).unwrap_or_default();
151                names.extend(members.iter().map(|m| format!("{name}.{}", py_str(m))));
152            }
153            None => names.push(name),
154        }
155    }
156    names
157}
158
159/// The 0.2 `--format names` module: `export type TagName = "..." | ...;`. `plcs` are as passed on the command line
160/// (one body each); names are qualified as `PLC/tag` when there is more than one.
161pub fn names_module(bodies: &[Json], plcs: &[Option<String>]) -> String {
162    let mut names: Vec<String> = Vec::new();
163    for (plc, body) in plcs.iter().zip(bodies) {
164        let prefix = if plcs.len() > 1 {
165            format!("{}/", plc.as_deref().unwrap_or("None"))
166        } else {
167            String::new()
168        };
169        let tags = body
170            .get("tags")
171            .and_then(Json::as_array)
172            .map(Vec::as_slice)
173            .unwrap_or_default();
174        for name in tag_names(tags) {
175            let name = format!("{prefix}{name}");
176            if !names.contains(&name) {
177                names.push(name);
178            }
179        }
180    }
181    let lines: String = names
182        .iter()
183        .map(|n| format!("\n  | {}", py_json_str(n)))
184        .collect();
185    let lines = if lines.is_empty() {
186        " never".to_owned()
187    } else {
188        lines
189    };
190    format!("// Generated by ciplink-server types. Do not edit.\nexport type TagName ={lines};\n")
191}
192
193/// The generated module: UDT interfaces, a tags interface and a tagTree const per PLC; `qualified` passes each
194/// PLC name to its tagTree (several PLCs).
195pub fn tree_module(bodies: &[Json], qualified: bool) -> Result<String, String> {
196    let mut parts = Vec::new();
197    let mut consts = String::new();
198    for body in bodies {
199        let mut tree = TreeGen::new(body)?;
200        parts.extend(tree.interfaces());
201        parts.push(tree.tags());
202        let plc = if qualified {
203            py_json_str(&tree.plc)
204        } else {
205            "null".to_owned()
206        };
207        let prefix = &tree.prefix;
208        consts.push_str(&format!(
209            "export const {prefix} = tagTree<{prefix}Tags>({plc}, {});\n",
210            tree.meta()
211        ));
212    }
213    let code = format!("{}\n{consts}", parts.join("\n"));
214    let mut imports = vec!["tagTree".to_owned()];
215    for t in ["TagRef", "IntRef", "TagArray"] {
216        let re = Regex::new(&format!(r"\b{t}<")).map_err(|e| e.to_string())?;
217        if re.is_match(&code) {
218            imports.push(format!("type {t}"));
219        }
220    }
221    let versions: Vec<String> = bodies
222        .iter()
223        .map(|b| {
224            let plc = py_str(b.get("plc").unwrap_or(&Json::Null));
225            let version = py_str(b.get("version").unwrap_or(&Json::Null));
226            format!("{plc} tag table version {version}")
227        })
228        .collect();
229    Ok(format!(
230        "// Generated by ciplink-server types ({}). Do not edit.\nimport {{ {} }} from \"@ciplink/react\";\n\n{code}",
231        versions.join(", "),
232        imports.join(", ")
233    ))
234}
235
236/// A TS identifier (`^[A-Za-z_$][A-Za-z0-9_$]*$`).
237fn is_ident(name: &str) -> bool {
238    let mut chars = name.chars();
239    chars
240        .next()
241        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
242        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
243}
244
245/// A TS identifier from a PLC or UDT name: other characters become `_`.
246fn ident(name: &str) -> String {
247    let name: String = name
248        .chars()
249        .map(|c| {
250            if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
251                c
252            } else {
253                '_'
254            }
255        })
256        .collect();
257    if is_ident(&name) {
258        name
259    } else {
260        format!("_{name}")
261    }
262}
263
264/// A TS object type; on one line when `indent` is None.
265fn obj(fields: &[(String, String)], indent: Option<usize>) -> String {
266    let key = |k: &str| {
267        if is_ident(k) {
268            k.to_owned()
269        } else {
270            py_json_str(k)
271        }
272    };
273    match indent {
274        None if fields.is_empty() => "{}".to_owned(),
275        None => {
276            let inner: Vec<String> = fields
277                .iter()
278                .map(|(k, v)| format!("{}: {v};", key(k)))
279                .collect();
280            format!("{{ {} }}", inner.join(" "))
281        }
282        Some(indent) => {
283            let pad = " ".repeat(indent + 2);
284            let inner: String = fields
285                .iter()
286                .map(|(k, v)| format!("{pad}{}: {v};\n", key(k)))
287                .collect();
288            format!("{{\n{inner}{}}}", " ".repeat(indent))
289        }
290    }
291}
292
293fn ts_bool(b: bool) -> &'static str {
294    if b { "true" } else { "false" }
295}
296
297/// A value as Python's `str()` formats it in an f-string (strings bare, null as None).
298fn py_str(value: &Json) -> String {
299    match value {
300        Json::String(s) => s.clone(),
301        Json::Null => "None".to_owned(),
302        Json::Bool(b) => if *b { "True" } else { "False" }.to_owned(),
303        other => py_json(other),
304    }
305}
306
307/// A string as Python's `json.dumps` writes it (`ensure_ascii`: non-ASCII as `\uXXXX`).
308fn py_json_str(s: &str) -> String {
309    let mut out = String::with_capacity(s.len() + 2);
310    out.push('"');
311    for c in s.chars() {
312        match c {
313            '"' => out.push_str("\\\""),
314            '\\' => out.push_str("\\\\"),
315            '\n' => out.push_str("\\n"),
316            '\r' => out.push_str("\\r"),
317            '\t' => out.push_str("\\t"),
318            '\u{8}' => out.push_str("\\b"),
319            '\u{c}' => out.push_str("\\f"),
320            ' '..='~' => out.push(c),
321            _ => {
322                let mut units = [0u16; 2];
323                for unit in c.encode_utf16(&mut units) {
324                    out.push_str(&format!("\\u{unit:04x}"));
325                }
326            }
327        }
328    }
329    out.push('"');
330    out
331}
332
333/// A value as Python's `json.dumps` writes it (`", "` and `": "` separators, ASCII only).
334fn py_json(value: &Json) -> String {
335    match value {
336        Json::String(s) => py_json_str(s),
337        Json::Array(items) => {
338            let items: Vec<String> = items.iter().map(py_json).collect();
339            format!("[{}]", items.join(", "))
340        }
341        Json::Object(map) => {
342            let items: Vec<String> = map
343                .iter()
344                .map(|(k, v)| format!("{}: {}", py_json_str(k), py_json(v)))
345                .collect();
346            format!("{{{}}}", items.join(", "))
347        }
348        other => other.to_string(),
349    }
350}
351
352/// Tags under a dotted prefix (`Program:X`), or a tag.
353enum Item<'a> {
354    Scope(IndexMap<String, Item<'a>>),
355    Tag(&'a Json),
356}
357
358/// TS types for one PLC's /tags body. Paths go two ways: `path` with each index as `[0]` (what settings are
359/// resolved for, amendment 36; None inside a shared interface) and `meta` with each index group as `[]`.
360struct TreeGen<'a> {
361    body: &'a Json,
362    types: Map<String, Json>,
363    resolver: Resolver,
364    plc: String,
365    prefix: String,
366    dims: Map<String, Json>,
367    bits: Map<String, Json>,
368}
369
370fn str_of<'a>(value: &'a Json, key: &str) -> &'a str {
371    value.get(key).and_then(Json::as_str).unwrap_or_default()
372}
373
374/// Python's `x.get("dims")` used as a truthy list.
375fn dims_of(value: &Json) -> &[Json] {
376    value
377        .get("dims")
378        .and_then(Json::as_array)
379        .map(Vec::as_slice)
380        .unwrap_or_default()
381}
382
383fn members_of(udt: &Json) -> impl Iterator<Item = (&String, &Json)> {
384    udt.get("members")
385        .and_then(Json::as_object)
386        .into_iter()
387        .flatten()
388}
389
390impl<'a> TreeGen<'a> {
391    fn new(body: &'a Json) -> Result<Self, String> {
392        let plc = body
393            .get("plc")
394            .and_then(Json::as_str)
395            .ok_or("a /tags body without its plc")?
396            .to_owned();
397        Ok(TreeGen {
398            body,
399            types: body
400                .get("types")
401                .and_then(Json::as_object)
402                .cloned()
403                .unwrap_or_default(),
404            resolver: Resolver::from_body(body)?, // the server's resolution: decorators, then rules
405            prefix: ident(&plc),
406            plc,
407            dims: Map::new(),
408            bits: Map::new(),
409        })
410    }
411
412    /// The path's effective setting of the field (a JSON null counts as unset).
413    fn rule(&self, path: Option<&str>, field: &str) -> Option<Json> {
414        self.resolver
415            .effective(path?)
416            .remove(field)
417            .filter(|v| !v.is_null())
418    }
419
420    fn writable(&self, path: Option<&str>) -> bool {
421        truthy(self.rule(path, "writable").as_ref())
422    }
423
424    /// (TS type, path specific) of an atomic, STRING or unknown leaf.
425    fn leaf(&mut self, type_: &str, path: Option<&str>, meta: Option<&str>) -> (String, bool) {
426        let w = self.writable(path);
427        let text = if INTS.contains(&type_) {
428            format!("IntRef<{}>", ts_bool(w))
429        } else if BIT_STRINGS.contains(&type_) {
430            format!(
431                "TagRef<boolean[], {0}> & {{ bit(n: number): TagRef<boolean, {0}> }}",
432                ts_bool(w)
433            )
434        } else {
435            let value = match type_ {
436                "BOOL" => "boolean",
437                "STRING" => "string",
438                "REAL" | "LREAL" => "number",
439                _ => "unknown",
440            };
441            return (format!("TagRef<{value}, {}>", ts_bool(w)), w);
442        };
443        let bits = match self.rule(path, "bits") {
444            Some(Json::Object(bits)) if !bits.is_empty() => bits,
445            _ => return (text, w),
446        };
447        if let Some(meta) = meta {
448            self.bits
449                .insert(meta.to_owned(), Json::Object(bits.clone()));
450        }
451        let mut aliases = Vec::with_capacity(bits.len());
452        for (name, n) in &bits {
453            // a bit's own settings first, else its word's writable
454            let bit = path.map(|p| format!("{p}.{}", py_str(n)));
455            let bw = self
456                .rule(bit.as_deref(), "writable")
457                .map_or(w, |v| truthy(Some(&v)));
458            aliases.push((name.clone(), format!("TagRef<boolean, {}>", ts_bool(bw))));
459        }
460        (format!("{text} & {}", obj(&aliases, None)), true)
461    }
462
463    /// (TS type, path specific) of a tag or member. Path specific (a writable path or bit names somewhere below)
464    /// means it is typed inline; otherwise a UDT uses its shared interface.
465    fn node(
466        &mut self,
467        type_: &str,
468        dims: &[Json],
469        path: Option<&str>,
470        meta: Option<&str>,
471        indent: usize,
472    ) -> (String, bool) {
473        if let Some((first, rest)) = dims.split_first() {
474            if let Some(meta) = meta {
475                self.dims
476                    .insert(meta.to_owned(), Json::Array(dims.to_vec()));
477            }
478            let zeros = vec!["0"; dims.len()].join(",");
479            let sub = path.map(|p| format!("{p}[{zeros}]"));
480            let sub_meta = meta.map(|m| format!("{m}[]"));
481            let (mut inner, special) =
482                self.node(type_, &[], sub.as_deref(), sub_meta.as_deref(), indent);
483            for n in rest.iter().rev() {
484                inner = format!("TagArray<{inner}, {}>", py_str(n));
485            }
486            // not a ref: a bare array name reads and writes its first element only (ciplink and the mock)
487            return (format!("TagArray<{inner}, {}>", py_str(first)), special);
488        }
489        let Some(udt) = self.types.get(type_).cloned() else {
490            return self.leaf(type_, path, meta);
491        };
492        let mut fields = Vec::new();
493        let mut special = false;
494        for (name, member) in members_of(&udt) {
495            if RESERVED.contains(&name.as_str()) {
496                continue;
497            }
498            let sub = path.map(|p| format!("{p}.{name}"));
499            let sub_meta = meta.map(|m| format!("{m}.{name}"));
500            let (text, s) = self.node(
501                str_of(member, "type"),
502                dims_of(member),
503                sub.as_deref(),
504                sub_meta.as_deref(),
505                indent + 2,
506            );
507            fields.push((name.clone(), text));
508            special |= s;
509        }
510        let w = self.writable(path);
511        if !(special || w) {
512            return (format!("{}_{}", self.prefix, ident(type_)), false);
513        }
514        // an AOI is never read whole
515        let head = if truthy(udt.get("aoi")) {
516            String::new()
517        } else {
518            format!("TagRef<Record<string, unknown>, {}> & ", ts_bool(w))
519        };
520        (head + &obj(&fields, Some(indent)), true)
521    }
522
523    fn interfaces(&mut self) -> Vec<String> {
524        let types = self.types.clone();
525        let mut out = Vec::with_capacity(types.len());
526        for (name, udt) in &types {
527            let mut fields = Vec::new();
528            for (m, info) in members_of(udt) {
529                if !RESERVED.contains(&m.as_str()) {
530                    let text = self
531                        .node(str_of(info, "type"), dims_of(info), None, None, 0)
532                        .0;
533                    fields.push((m.clone(), text));
534                }
535            }
536            let base = if truthy(udt.get("aoi")) {
537                ""
538            } else {
539                " extends TagRef<Record<string, unknown>, false>"
540            };
541            out.push(format!(
542                "export interface {}_{}{base} {}\n",
543                self.prefix,
544                ident(name),
545                obj(&fields, Some(0))
546            ));
547        }
548        out
549    }
550
551    /// The `<PLC>Tags` interface; a dotted tag name (`Program:X.Tag`) nests under its prefix.
552    fn tags(&mut self) -> String {
553        let mut root: IndexMap<String, Item> = IndexMap::new();
554        let tags = self
555            .body
556            .get("tags")
557            .and_then(Json::as_array)
558            .map(Vec::as_slice)
559            .unwrap_or_default();
560        'tags: for tag in tags {
561            let name = str_of(tag, "name");
562            let mut scopes: Vec<&str> = name.split('.').collect();
563            let last = scopes.pop().unwrap_or_default(); // split yields at least one part
564            let mut level = &mut root;
565            for scope in scopes {
566                match level
567                    .entry(scope.to_owned())
568                    .or_insert_with(|| Item::Scope(IndexMap::new()))
569                {
570                    Item::Scope(inner) => level = inner,
571                    Item::Tag(_) => continue 'tags,
572                }
573            }
574            level.entry(last.to_owned()).or_insert(Item::Tag(tag));
575        }
576        let tree = self.emit(&root, "", 0);
577        format!("export interface {}Tags {tree}\n", self.prefix)
578    }
579
580    fn emit(&mut self, level: &IndexMap<String, Item>, prefix: &str, indent: usize) -> String {
581        let mut fields = Vec::new();
582        for (name, item) in level {
583            if RESERVED.contains(&name.as_str()) {
584                continue;
585            }
586            let path = format!("{prefix}{name}");
587            let text = match item {
588                Item::Scope(inner) => self.emit(inner, &format!("{path}."), indent + 2),
589                Item::Tag(tag) => {
590                    let type_ = str_of(tag, "type");
591                    let listed = tag
592                        .get("type")
593                        .and_then(Json::as_str)
594                        .is_some_and(|t| self.types.contains_key(t));
595                    match tag.get("aoi_readable") {
596                        Some(readable) if !listed => {
597                            // AOI type not listed
598                            let members =
599                                readable.as_array().map(Vec::as_slice).unwrap_or_default();
600                            let mut leaves = Vec::with_capacity(members.len());
601                            for m in members {
602                                let m = py_str(m);
603                                let text = self.leaf("", Some(&format!("{path}.{m}")), None).0;
604                                leaves.push((m, text));
605                            }
606                            obj(&leaves, Some(indent + 2))
607                        }
608                        _ => {
609                            self.node(type_, dims_of(tag), Some(&path), Some(&path), indent + 2)
610                                .0
611                        }
612                    }
613                }
614            };
615            fields.push((name.clone(), text));
616        }
617        obj(&fields, Some(indent))
618    }
619
620    /// tagTree meta: dims of every array path, bit names of integer paths. Call after `tags()`.
621    fn meta(&self) -> String {
622        let mut meta = Map::new();
623        if !self.dims.is_empty() {
624            meta.insert("dims".into(), Json::Object(self.dims.clone()));
625        }
626        if !self.bits.is_empty() {
627            meta.insert("bits".into(), Json::Object(self.bits.clone()));
628        }
629        py_json(&Json::Object(meta))
630    }
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used)]
635mod tests {
636    use std::net::TcpListener;
637    use std::path::PathBuf;
638    use std::process::Command;
639    use std::thread;
640
641    use serde_json::json;
642
643    use super::*;
644
645    /// A /tags body with every shape codegen handles (tests/test_types.py BODY).
646    fn body() -> Json {
647        json!({
648            "plc": "PLC1", "state": "connected", "version": 1234,
649            "tags": [
650                {"name": "Bench", "type": "BenchData"},
651                {"name": "Tank", "type": "Tank", "dims": [4]},
652                {"name": "Trend", "type": "REAL", "dims": [16]},
653                {"name": "Grid", "type": "DINT", "dims": [3, 4]},
654                {"name": "Flags", "type": "BOOL", "dims": [32]},
655                {"name": "Message", "type": "STRING"},
656                {"name": "Valve1", "type": "MyAOI", "aoi_readable": ["Open", "Cmd"]},
657                {"name": "Pump-2", "type": "REAL"},
658                {"name": "Program:Bench.Mode", "type": "DINT"},
659                {"name": "Program:Bench.Cell", "type": "Tank", "dims": [2]},
660                {"name": "Old", "type": "OldAOI", "aoi_readable": ["Done"]},
661            ],
662            "types": {
663                "BenchData": {"members": {
664                    "Running": {"type": "BOOL"}, "StartPB": {"type": "BOOL"}, "SpeedSP": {"type": "REAL"},
665                    "Speed": {"type": "REAL"}, "StatusWord": {"type": "DINT"}, "Count": {"type": "DINT"},
666                    "Message": {"type": "STRING"}, "Axis": {"type": "Axis"}, "Recipe": {"type": "Recipe"},
667                    "length": {"type": "DINT"}}},
668                "Axis": {"members": {"Position": {"type": "REAL"}, "Velocity": {"type": "REAL"}}},
669                "Recipe": {"members": {"Name": {"type": "STRING"}, "Setpoints": {"type": "REAL", "dims": [4]}}},
670                "Tank": {"members": {"Level": {"type": "REAL"}, "Temp": {"type": "REAL"}, "Valve": {"type": "BOOL"},
671                                     "Status": {"type": "INT"}}},
672                "MyAOI": {"members": {"Open": {"type": "BOOL"}, "Cmd": {"type": "DINT"}}, "aoi": true},
673            },
674            "rules": [
675                {"match": "Bench.SpeedSP", "writable": true, "min": 0, "max": 1800},
676                {"match": ["Bench.StartPB", "Tank[*].Valve"], "writable": true},
677                {"match": "Bench.StatusWord.1", "writable": true},
678                {"match": "Bench.StatusWord", "always": true, "bits": {"EStop": 0, "DoorOpen": 1}},
679                {"match": "Tank[*].Status", "bits": {"Alarm": 3}},
680                {"match": "Grid*", "writable": true},
681            ],
682            "groups": [],
683        })
684    }
685
686    fn between<'t>(text: &'t str, start: &str, end: &str) -> &'t str {
687        let rest = &text[text.find(start).expect(start) + start.len()..];
688        &rest[..rest.rfind(end).expect(end)]
689    }
690
691    // The server-backed tests of test_types.py (one PLC, several PLCs, the mock server) use the bodies the
692    // Python server returns for them; the server itself is tested elsewhere.
693
694    #[test]
695    fn names_of_one_plc_list_aoi_readable_members() {
696        let body = json!({"plc": "PLC1", "tags": [
697            {"name": "Line1.Speed", "type": "REAL"},
698            {"name": "Valve1", "type": "MyAOI", "aoi_readable": ["Open", "Cmd"]}]});
699        let text = names_module(&[body], &[Some("PLC1".into())]);
700        assert!(text.starts_with("// Generated by ciplink-server types. Do not edit.\n"));
701        assert!(text.contains(
702            "export type TagName =\n  | \"Line1.Speed\"\n  | \"Valve1.Open\"\n  | \"Valve1.Cmd\";\n"
703        ));
704        assert!(!text.contains("Local1"));
705    }
706
707    #[test]
708    fn names_are_qualified_with_several_plcs() {
709        let one = json!({"plc": "PLC1", "tags": [{"name": "Line1.Speed", "type": "REAL"}]});
710        let two = json!({"plc": "PLC2", "tags": [
711            {"name": "Program:Station1.DoorOpen", "type": "REAL"},
712            {"name": "Valve1", "type": "MyAOI", "aoi_readable": ["Open", "Cmd"]}]});
713        let text = names_module(&[one, two], &[Some("PLC1".into()), Some("PLC2".into())]);
714        assert!(text.ends_with(
715            "  | \"PLC1/Line1.Speed\"\n  | \"PLC2/Program:Station1.DoorOpen\"\n  | \"PLC2/Valve1.Open\"\n  | \"PLC2/Valve1.Cmd\";\n"
716        ));
717    }
718
719    #[test]
720    fn names_without_tags_are_never_and_deduplicated() {
721        let empty = names_module(&[json!({"plc": "P", "tags": []})], &[None]);
722        assert!(empty.ends_with("export type TagName = never;\n"));
723        let body = json!({"plc": "P", "tags": [{"name": "A"}, {"name": "A"}, {"name": "Ü"}]});
724        assert!(
725            names_module(&[body], &[None]).ends_with("TagName =\n  | \"A\"\n  | \"\\u00dc\";\n")
726        );
727    }
728
729    #[test]
730    fn tree_shapes() {
731        let text = tree_module(&[body()], false).unwrap();
732        assert!(text.starts_with(
733            "// Generated by ciplink-server types (PLC1 tag table version 1234). Do not edit.\n"
734        ));
735        assert!(text.contains(
736            "import { tagTree, type TagRef, type IntRef, type TagArray } from \"@ciplink/react\";"
737        ));
738        assert!(text.contains(
739            "export interface PLC1_Axis extends TagRef<Record<string, unknown>, false> {\n  Position: TagRef<number, false>;\n  Velocity: TagRef<number, false>;\n}"
740        ));
741        assert!(text.contains(
742            "export interface PLC1_MyAOI {\n  Open: TagRef<boolean, false>;\n  Cmd: IntRef<false>;\n}"
743        ));
744        assert!(text.contains("  Setpoints: TagArray<TagRef<number, false>, 4>;\n"));
745        let bench = text
746            .split("export interface PLC1_BenchData")
747            .nth(1)
748            .unwrap();
749        assert!(!bench.split('}').next().unwrap().contains("length")); // a proxy name
750        let tags = text.split("export interface PLC1Tags ").nth(1).unwrap();
751        assert!(
752            tags.contains(
753                "    SpeedSP: TagRef<number, true>;\n    Speed: TagRef<number, false>;\n"
754            )
755        );
756        assert!(tags.contains("    StartPB: TagRef<boolean, true>;\n"));
757        assert!(tags.contains(
758            "    StatusWord: IntRef<false> & { EStop: TagRef<boolean, false>; DoorOpen: TagRef<boolean, true>; };\n"
759        ));
760        // nothing writable below: shared interfaces
761        assert!(tags.contains("    Axis: PLC1_Axis;\n    Recipe: PLC1_Recipe;\n"));
762        // Valve writable: inline
763        assert!(tags.contains("  Tank: TagArray<TagRef<Record<string, unknown>, false> & {\n"));
764        assert!(tags.contains(
765            "    Valve: TagRef<boolean, true>;\n    Status: IntRef<false> & { Alarm: TagRef<boolean, false>; };\n"
766        ));
767        assert!(tags.contains("  Grid: TagArray<TagArray<IntRef<true>, 4>, 3>;\n"));
768        assert!(tags.contains("  Flags: TagArray<TagRef<boolean, false>, 32>;\n"));
769        assert!(tags.contains("  Message: TagRef<string, false>;\n  Valve1: PLC1_MyAOI;\n"));
770        assert!(tags.contains(
771            "  \"Pump-2\": TagRef<number, false>;\n  \"Program:Bench\": {\n    Mode: IntRef<false>;\n"
772        ));
773        // AOI type not listed
774        assert!(tags.contains("  Old: {\n    Done: TagRef<unknown, false>;\n  };\n"));
775        let meta = between(&text, "tagTree<PLC1Tags>(null, ", ");");
776        assert_eq!(
777            meta,
778            "{\"dims\": {\"Bench.Recipe.Setpoints\": [4], \"Tank\": [4], \"Trend\": [16], \"Grid\": [3, 4], \
779             \"Flags\": [32], \"Program:Bench.Cell\": [2]}, \"bits\": {\"Bench.StatusWord\": {\"EStop\": 0, \
780             \"DoorOpen\": 1}, \"Tank[].Status\": {\"Alarm\": 3}}}"
781        );
782    }
783
784    #[test]
785    fn tree_qualified_with_several_plcs() {
786        let mut two = body();
787        two["plc"] = json!("Line 2");
788        two["version"] = json!(7);
789        let text = tree_module(&[body(), two], true).unwrap();
790        let first = text.lines().next().unwrap();
791        assert!(first.contains("PLC1 tag table version 1234, Line 2 tag table version 7"));
792        assert!(text.contains("export const PLC1 = tagTree<PLC1Tags>(\"PLC1\", {"));
793        assert!(text.contains("export const Line_2 = tagTree<Line_2Tags>(\"Line 2\", {"));
794        assert!(text.contains("export interface Line_2_Axis "));
795    }
796
797    #[test]
798    fn tree_from_mock_server_body() {
799        let body = json!({
800            "plc": "PLC1", "version": 1,
801            "tags": [
802                {"name": "Tank", "type": "Tank", "dims": [4]},
803                {"name": "Trend", "type": "REAL", "dims": [16]},
804                {"name": "Program:Bench.Mode", "type": "DINT"}],
805            "types": {"Tank": {"members": {"Level": {"type": "REAL"}, "Valve": {"type": "BOOL"}}}},
806            "rules": [{"match": "Tank[*].Valve", "writable": true}],
807            "groups": [], "decorators": {}, "udt": {}, "tag_settings": {},
808        });
809        let text = tree_module(&[body], false).unwrap();
810        assert!(text.contains(
811            "export interface PLC1_Tank extends TagRef<Record<string, unknown>, false> {"
812        ));
813        assert!(text.contains("    Valve: TagRef<boolean, true>;\n"));
814        assert!(text.contains("  Trend: TagArray<TagRef<number, false>, 16>;\n"));
815        assert!(text.contains("  \"Program:Bench\": {\n    Mode: IntRef<false>;\n  };\n"));
816        assert!(text.contains(
817            "export const PLC1 = tagTree<PLC1Tags>(null, {\"dims\": {\"Tank\": [4], \"Trend\": [16]}});"
818        ));
819    }
820
821    #[test]
822    fn bit_strings_are_bool_lists_with_bit_names() {
823        let body = json!({
824            "plc": "P", "version": 1,
825            "tags": [{"name": "B", "type": "BYTE"}, {"name": "W", "type": "WORD"}, {"name": "D", "type": "DWORD"},
826                     {"name": "L", "type": "LWORD"}],
827            "rules": [{"match": "W", "writable": true, "bits": {"Run": 2}}],
828        });
829        let text = tree_module(&[body], false).unwrap();
830        let bits = "TagRef<boolean[], false> & { bit(n: number): TagRef<boolean, false> }";
831        for name in ["B", "D", "L"] {
832            assert!(text.contains(&format!("  {name}: {bits};\n")), "{name}");
833        }
834        assert!(text.contains(
835            "  W: TagRef<boolean[], true> & { bit(n: number): TagRef<boolean, true> } & { Run: TagRef<boolean, true>; };\n"
836        ));
837        assert!(!text.contains("IntRef"));
838        assert!(text.contains("import { tagTree, type TagRef } from"));
839        assert!(text.contains("{\"bits\": {\"W\": {\"Run\": 2}}}"));
840    }
841
842    #[test]
843    fn non_ascii_names_are_escaped_like_json_dumps() {
844        let body = json!({"plc": "Zürich", "version": null,
845                          "tags": [{"name": "Grüße", "type": "REAL", "dims": [2]}]});
846        let text = tree_module(&[body], true).unwrap();
847        assert!(
848            text.starts_with(
849                "// Generated by ciplink-server types (Zürich tag table version None)."
850            )
851        );
852        assert!(text.contains("  \"Gr\\u00fc\\u00dfe\": TagArray<TagRef<number, false>, 2>;\n"));
853        assert!(text.contains(
854            "export const Z_rich = tagTree<Z_richTags>(\"Z\\u00fcrich\", {\"dims\": {\"Gr\\u00fc\\u00dfe\": [2]}});"
855        ));
856        assert_eq!(
857            py_json_str("a\"\\\n\u{1}\u{7f}\u{1F600}"),
858            "\"a\\\"\\\\\\n\\u0001\\u007f\\ud83d\\ude00\""
859        );
860    }
861
862    #[test]
863    fn idents_replace_other_characters() {
864        assert_eq!(ident("Line 2"), "Line_2");
865        assert_eq!(ident("2nd"), "_2nd");
866        assert_eq!(ident("$ok_1"), "$ok_1");
867        assert_eq!(obj(&[], None), "{}");
868        assert_eq!(
869            obj(&[("a-b".into(), "X".into())], Some(2)),
870            "{\n    \"a-b\": X;\n  }"
871        );
872    }
873
874    /// Serves one canned response; the handle returns the request it read.
875    fn serve(response: &'static [u8]) -> (String, thread::JoinHandle<String>) {
876        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
877        let url = format!("http://{}/", listener.local_addr().unwrap());
878        let handle = thread::spawn(move || {
879            let (mut stream, _) = listener.accept().unwrap();
880            let mut request = Vec::new();
881            let mut buf = [0u8; 1024];
882            while !request.ends_with(b"\r\n\r\n") {
883                let n = stream.read(&mut buf).unwrap();
884                assert!(n > 0, "request cut short");
885                request.extend_from_slice(&buf[..n]);
886            }
887            stream.write_all(response).unwrap();
888            String::from_utf8(request).unwrap()
889        });
890        (url, handle)
891    }
892
893    #[test]
894    fn fetch_tags_reads_a_content_length_body() {
895        let (url, server) = serve(
896            b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\ncontent-length: 13\r\n\r\n{\"plc\": \"P\"}\nextra",
897        );
898        let body = fetch_tags(&url, "view", Some("Line 2/\u{fc}")).unwrap();
899        assert_eq!(body, json!({"plc": "P"}));
900        let request = server.join().unwrap();
901        assert!(
902            request.starts_with("GET /tags?plc=Line%202/%C3%BC HTTP/1.1\r\n"),
903            "{request}"
904        );
905        assert!(request.contains("\r\nAuthorization: Bearer view\r\n"));
906        assert!(request.contains("\r\nConnection: close\r\n"));
907    }
908
909    #[test]
910    fn fetch_tags_reads_a_chunked_body() {
911        let (url, server) = serve(
912            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\n{\"plc\r\n7;x=1\r\n\": \"P\"}\r\n0\r\n\r\n",
913        );
914        let body = fetch_tags(url.trim_end_matches('/'), "t", None).unwrap();
915        assert_eq!(body, json!({"plc": "P"}));
916        assert!(server.join().unwrap().starts_with("GET /tags HTTP/1.1\r\n"));
917    }
918
919    #[test]
920    fn fetch_tags_fails_on_an_error_status() {
921        let (url, server) =
922            serve(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 9\r\n\r\nbad token");
923        let err = fetch_tags(&url, "x", None).unwrap_err();
924        assert!(
925            err.contains("HTTP/1.1 401 Unauthorized") && err.contains("bad token"),
926            "{err}"
927        );
928        server.join().unwrap();
929        assert!(fetch_tags("https://example.com", "x", None).is_err());
930    }
931
932    const CHECKS: &str = r#"import { useMomentary, useTag, useTags, useWriteTag } from "@ciplink/react";
933import { PLC1, type PLC1_Tank } from "./plc";
934
935export function checks(tank: PLC1_Tank) {
936  useWriteTag(PLC1.Bench.SpeedSP)(5);
937  useWriteTag(PLC1.Tank[2].Valve)(true);
938  useWriteTag(PLC1.Grid[1][2])(3);
939  useMomentary(PLC1.Bench.StartPB);
940  // @ts-expect-error a read-only leaf
941  useWriteTag(PLC1.Bench.Speed);
942  // @ts-expect-error read-only through a shared interface
943  useWriteTag(PLC1.Bench.Axis.Position);
944  // @ts-expect-error not writable
945  useWriteTag(PLC1.Tank[1].Level);
946  // @ts-expect-error not writable
947  useMomentary(PLC1.Bench.Running);
948  // @ts-expect-error a REAL takes a number
949  useWriteTag(PLC1.Bench.SpeedSP)("fast");
950  // @ts-expect-error the word is read-only
951  useWriteTag(PLC1.Bench.StatusWord);
952  useWriteTag(PLC1.Bench.StatusWord.DoorOpen)(true);
953  // @ts-expect-error EStop is read-only
954  useWriteTag(PLC1.Bench.StatusWord.EStop);
955  const estop: boolean | null = useTag(PLC1.Bench.StatusWord.EStop).value;
956  const bit: boolean | null = useTag(PLC1.Bench.Count.bit(3)).value;
957  const alarm: boolean | null = useTag(PLC1.Tank[0].Status.Alarm).value;
958  const grid: number | null = useTag(PLC1.Grid[1][2]).value;
959  const n: 4 = PLC1.Tank.length;
960  const m: 16 = PLC1.Trend.length;
961  const mode: number | null = useTag(PLC1["Program:Bench"].Mode).value;
962  const cell: number | null = useTag(PLC1["Program:Bench"].Cell[1].Level).value;
963  const text: string | null = useTag(PLC1.Message).value;
964  const name: string | null = useTag(PLC1.Bench.Recipe.Name).value;
965  const sp: number | null = useTag(PLC1.Bench.Recipe.Setpoints[3]).value;
966  const flag: boolean | null = useTag(PLC1.Flags[31]).value;
967  const open: boolean | null = useTag(PLC1.Valve1.Open).value;
968  const pump: number | null = useTag(PLC1["Pump-2"]).value;
969  useTag(PLC1.Old.Done);
970  useTags([PLC1.Bench.Recipe, PLC1.Trend[0], tank.Level]);
971  // @ts-expect-error a bare array name is its first element, not the array
972  useTag(PLC1.Trend);
973  // @ts-expect-error AOI members are not readable whole
974  useTag(PLC1.Valve1);
975  return [estop, bit, alarm, grid, n, m, mode, cell, text, name, sp, flag, open, pump];
976}
977"#;
978
979    /// Type-checks the module against `@ciplink/react`: needs node and the client with its node_modules, at
980    /// `CIPLINK_REACT_CLIENT` or in a ciplink-tagserver checkout next to this repository; skipped otherwise.
981    #[test]
982    fn tree_compiles() {
983        let client = std::env::var_os("CIPLINK_REACT_CLIENT")
984            .map(PathBuf::from)
985            .unwrap_or_else(|| {
986                PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../ciplink-tagserver/client")
987            });
988        let tsc = client.join("node_modules/typescript/bin/tsc");
989        if !tsc.exists() || Command::new("node").arg("--version").output().is_err() {
990            eprintln!("skipped: needs node and {}", tsc.display());
991            return;
992        }
993        let dir = std::env::temp_dir().join(format!("ciplink-codegen-{}", std::process::id()));
994        std::fs::create_dir_all(&dir).unwrap();
995        std::fs::write(dir.join("plc.ts"), tree_module(&[body()], false).unwrap()).unwrap();
996        std::fs::write(dir.join("checks.ts"), CHECKS).unwrap();
997        let index = client
998            .join("src/index.ts")
999            .to_string_lossy()
1000            .replace('\\', "/");
1001        let config = json!({
1002            "compilerOptions": {"target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler",
1003                                "strict": true, "jsx": "react-jsx", "lib": ["ES2020", "DOM", "DOM.Iterable"],
1004                                "skipLibCheck": true, "noEmit": true, "noUnusedLocals": true, "types": [],
1005                                "paths": {"@ciplink/react": [index]}},
1006            "files": ["plc.ts", "checks.ts"]});
1007        std::fs::write(dir.join("tsconfig.json"), config.to_string()).unwrap();
1008        let out = Command::new("node")
1009            .arg(&tsc)
1010            .arg("-p")
1011            .arg(dir.join("tsconfig.json"))
1012            .output()
1013            .unwrap();
1014        let _ = std::fs::remove_dir_all(&dir);
1015        assert!(
1016            out.status.success(),
1017            "{}{}",
1018            String::from_utf8_lossy(&out.stdout),
1019            String::from_utf8_lossy(&out.stderr)
1020        );
1021    }
1022}