1use 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
14const INTS: [&str; 8] = [
17 "SINT", "INT", "DINT", "LINT", "USINT", "UINT", "UDINT", "ULINT",
18];
19const BIT_STRINGS: [&str; 4] = ["BYTE", "WORD", "DWORD", "LWORD"];
21const RESERVED: [&str; 5] = ["bit", "length", "then", "toString", "toJSON"];
23
24pub 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
71fn 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
84fn 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); }
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
143pub 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
159pub 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
193pub 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
236fn 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
245fn 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
264fn 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
297fn 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
307fn 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
333fn 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
352enum Item<'a> {
354 Scope(IndexMap<String, Item<'a>>),
355 Tag(&'a Json),
356}
357
358struct 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
374fn 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)?, prefix: ident(&plc),
406 plc,
407 dims: Map::new(),
408 bits: Map::new(),
409 })
410 }
411
412 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 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 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 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 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 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 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(); 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 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 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 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 #[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")); 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 assert!(tags.contains(" Axis: PLC1_Axis;\n Recipe: PLC1_Recipe;\n"));
762 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 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 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 #[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}