1use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SwiftParam {
28 pub label: Option<String>,
31 pub name: String,
33 pub ty: SwiftType,
34}
35
36impl SwiftParam {
37 pub fn key(&self) -> &str {
40 self.label.as_deref().unwrap_or(&self.name)
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SwiftType {
47 String,
48 Int,
49 Double,
50 Bool,
51}
52
53impl SwiftType {
54 fn parse(ty: &str) -> Option<Self> {
55 match ty {
56 "String" => Some(SwiftType::String),
57 "Int" => Some(SwiftType::Int),
58 "Double" => Some(SwiftType::Double),
59 "Bool" => Some(SwiftType::Bool),
60 _ => None,
61 }
62 }
63 pub fn swift(self) -> &'static str {
65 match self {
66 SwiftType::String => "String",
67 SwiftType::Int => "Int",
68 SwiftType::Double => "Double",
69 SwiftType::Bool => "Bool",
70 }
71 }
72 pub fn rust(self) -> &'static str {
74 match self {
75 SwiftType::String => "String",
76 SwiftType::Int => "i64",
77 SwiftType::Double => "f64",
78 SwiftType::Bool => "bool",
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SwiftView {
87 pub module: String,
89 pub name: String,
91 pub params: Vec<SwiftParam>,
93 pub source: String,
95}
96
97impl SwiftView {
98 pub fn piece_name(&self) -> String {
100 format!("{}.{}", self.module, self.name)
101 }
102 pub fn class_name(&self) -> String {
104 format!("DayView_{}_{}", self.module, self.name)
105 }
106}
107
108#[derive(Debug, Default, Clone, PartialEq, Eq)]
111pub struct SwiftScan {
112 pub views: Vec<SwiftView>,
113 pub skipped: Vec<(String, String)>,
115}
116
117pub fn scan_package(pkg_dir: &Path) -> Result<SwiftScan, String> {
120 if !pkg_dir.join("Package.swift").is_file() {
121 return Err(format!(
122 "day-build: {} has no Package.swift — a local swift-packages path must point at a SwiftPM package root",
123 pkg_dir.display()
124 ));
125 }
126 let sources = pkg_dir.join("Sources");
127 let mut modules: Vec<PathBuf> = std::fs::read_dir(&sources)
128 .map_err(|_| {
129 format!(
130 "day-build: {} has no Sources/ directory — the view scan needs the conventional \
131 Sources/<Module>/ layout",
132 pkg_dir.display()
133 )
134 })?
135 .flatten()
136 .map(|e| e.path())
137 .filter(|p| p.is_dir())
138 .collect();
139 modules.sort();
140
141 let mut scan = SwiftScan::default();
142 for module_dir in modules {
143 let module = module_dir
144 .file_name()
145 .map(|s| s.to_string_lossy().into_owned())
146 .unwrap_or_default();
147 let mut files = Vec::new();
148 collect_swift_files(&module_dir, &mut files);
149 files.sort();
150 for file in files {
151 let src = std::fs::read_to_string(&file)
152 .map_err(|e| format!("day-build: reading {}: {e}", file.display()))?;
153 let rel = file
154 .strip_prefix(pkg_dir)
155 .unwrap_or(&file)
156 .to_string_lossy()
157 .replace('\\', "/");
158 scan_source(&module, &rel, &src, &mut scan);
159 }
160 }
161 scan.views
162 .sort_by(|a, b| (&a.module, &a.name).cmp(&(&b.module, &b.name)));
163 scan.skipped.sort();
164
165 for pair in scan.views.windows(2) {
168 if pair[0].name == pair[1].name {
169 return Err(format!(
170 "day-build: two exported views are both named `{}` ({} and {}) — \
171 `crate::swiftui` is flat, rename one",
172 pair[0].name,
173 pair[0].piece_name(),
174 pair[1].piece_name()
175 ));
176 }
177 }
178 Ok(scan)
179}
180
181fn collect_swift_files(dir: &Path, out: &mut Vec<PathBuf>) {
182 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
183 let path = entry.path();
184 if path.is_dir() {
185 collect_swift_files(&path, out);
186 } else if path.extension().and_then(|e| e.to_str()) == Some("swift") {
187 out.push(path);
188 }
189 }
190}
191
192pub fn scan_source(module: &str, source: &str, src: &str, scan: &mut SwiftScan) {
194 let text = strip_comments_and_strings(src);
195 let bytes = text.as_bytes();
196 let mut depth = 0i32;
197 let mut i = 0usize;
198 while i < bytes.len() {
199 match bytes[i] {
200 b'{' => depth += 1,
201 b'}' => depth -= 1,
202 b'p' if depth == 0 && text[i..].starts_with("public struct ") => {
203 if let Some((decl, next)) = parse_struct(module, source, &text, i) {
204 match decl {
205 Ok(view) => scan.views.push(view),
206 Err(Some(skip)) => scan.skipped.push(skip),
207 Err(None) => {}
208 }
209 i = next;
210 continue;
211 }
212 }
213 _ => {}
214 }
215 i += 1;
216 }
217}
218
219#[allow(clippy::type_complexity)]
222fn parse_struct(
223 module: &str,
224 source: &str,
225 text: &str,
226 start: usize,
227) -> Option<(Result<SwiftView, Option<(String, String)>>, usize)> {
228 let after_kw = start + "public struct ".len();
229 let name: String = text[after_kw..]
230 .chars()
231 .take_while(|c| c.is_alphanumeric() || *c == '_')
232 .collect();
233 if name.is_empty() {
234 return None;
235 }
236 let brace = text[after_kw..].find('{')? + after_kw;
238 let clause = &text[after_kw + name.len()..brace];
239 let body_end = matching_brace(text, brace)?;
240 let full = format!("{module}.{name}");
241 if !names_view(clause) {
242 return Some((Err(None), body_end));
243 }
244 if clause.trim_start().starts_with('<') {
245 return Some((
246 Err(Some((full, "generic views are not supported".into()))),
247 body_end,
248 ));
249 }
250 let body = &text[brace..body_end];
252 let Some(init_at) = body.find("public init") else {
253 return Some((
254 Err(Some((
255 full,
256 "no public init (the memberwise init is internal)".into(),
257 ))),
258 body_end,
259 ));
260 };
261 let after_init = &body[init_at + "public init".len()..];
262 let trimmed = after_init.trim_start();
263 if !trimmed.starts_with('(') {
264 let reason = if trimmed.starts_with('?') {
265 "failable inits are not supported"
266 } else {
267 "generic inits are not supported"
268 };
269 return Some((Err(Some((full, reason.into()))), body_end));
270 }
271 let open = body[init_at..].find('(').unwrap() + init_at;
272 let close = matching_paren(body, open)?;
273 match parse_params(&body[open + 1..close]) {
274 Ok(params) => Some((
275 Ok(SwiftView {
276 module: module.to_string(),
277 name,
278 params,
279 source: source.to_string(),
280 }),
281 body_end,
282 )),
283 Err(reason) => Some((Err(Some((full, reason))), body_end)),
284 }
285}
286
287fn names_view(clause: &str) -> bool {
289 let clause = clause.split("where").next().unwrap_or(clause);
290 clause
291 .split([':', ','])
292 .map(str::trim)
293 .any(|c| c == "View" || c == "SwiftUI.View")
294}
295
296fn parse_params(list: &str) -> Result<Vec<SwiftParam>, String> {
298 let mut params = Vec::new();
299 for piece in split_top_level(list) {
300 let piece = piece.trim();
301 if piece.is_empty() {
302 continue;
303 }
304 if piece.contains('=') {
305 return Err("default parameter values are not supported".into());
306 }
307 if piece.starts_with('@') {
308 return Err(format!(
309 "attributed parameters are not supported ({})",
310 piece.split_whitespace().next().unwrap_or("@")
311 ));
312 }
313 let (names, ty) = piece
314 .split_once(':')
315 .ok_or_else(|| format!("could not parse parameter `{piece}`"))?;
316 let ty = ty.trim();
317 if ty.starts_with("inout") || ty.ends_with("...") {
318 return Err("inout/variadic parameters are not supported".into());
319 }
320 let ty = SwiftType::parse(ty).ok_or_else(|| {
321 format!("parameter type `{ty}` is not supported (String/Int/Double/Bool)")
322 })?;
323 let mut words = names.split_whitespace();
324 let (label, name) = match (words.next(), words.next(), words.next()) {
325 (Some(one), None, _) => (Some(one.to_string()), one.to_string()),
326 (Some("_"), Some(name), None) => (None, name.to_string()),
327 (Some(label), Some(name), None) => (Some(label.to_string()), name.to_string()),
328 _ => return Err(format!("could not parse parameter `{piece}`")),
329 };
330 params.push(SwiftParam { label, name, ty });
331 }
332 Ok(params)
333}
334
335fn split_top_level(s: &str) -> Vec<&str> {
337 let mut out = Vec::new();
338 let mut depth = 0i32;
339 let mut start = 0usize;
340 for (i, c) in s.char_indices() {
341 match c {
342 '(' | '[' | '<' => depth += 1,
343 ')' | ']' | '>' => depth -= 1,
344 ',' if depth == 0 => {
345 out.push(&s[start..i]);
346 start = i + 1;
347 }
348 _ => {}
349 }
350 }
351 out.push(&s[start..]);
352 out
353}
354
355fn matching_brace(text: &str, open: usize) -> Option<usize> {
357 let mut depth = 0i32;
358 for (i, b) in text.bytes().enumerate().skip(open) {
359 match b {
360 b'{' => depth += 1,
361 b'}' => {
362 depth -= 1;
363 if depth == 0 {
364 return Some(i + 1);
365 }
366 }
367 _ => {}
368 }
369 }
370 None
371}
372
373fn matching_paren(text: &str, open: usize) -> Option<usize> {
375 let mut depth = 0i32;
376 for (i, b) in text.bytes().enumerate().skip(open) {
377 match b {
378 b'(' => depth += 1,
379 b')' => {
380 depth -= 1;
381 if depth == 0 {
382 return Some(i);
383 }
384 }
385 _ => {}
386 }
387 }
388 None
389}
390
391fn strip_comments_and_strings(src: &str) -> String {
396 #[derive(PartialEq)]
397 enum State {
398 Code,
399 Line,
400 Block(u32),
401 Str,
402 MultiStr,
403 }
404 let mut out = String::with_capacity(src.len());
405 let mut state = State::Code;
406 let chars: Vec<char> = src.chars().collect();
407 let mut i = 0;
408 while i < chars.len() {
409 let c = chars[i];
410 let next = chars.get(i + 1).copied();
411 match state {
412 State::Code => match (c, next) {
413 ('/', Some('/')) => {
414 state = State::Line;
415 out.push_str(" ");
416 i += 2;
417 continue;
418 }
419 ('/', Some('*')) => {
420 state = State::Block(1);
421 out.push_str(" ");
422 i += 2;
423 continue;
424 }
425 ('"', _) if chars.get(i + 1) == Some(&'"') && chars.get(i + 2) == Some(&'"') => {
426 state = State::MultiStr;
427 out.push_str("\"\"\"");
428 i += 3;
429 continue;
430 }
431 ('"', _) => {
432 state = State::Str;
433 out.push('"');
434 }
435 _ => out.push(c),
436 },
437 State::Line => {
438 if c == '\n' {
439 state = State::Code;
440 out.push('\n');
441 } else {
442 out.push(' ');
443 }
444 }
445 State::Block(depth) => match (c, next) {
446 ('/', Some('*')) => {
447 state = State::Block(depth + 1);
448 out.push_str(" ");
449 i += 2;
450 continue;
451 }
452 ('*', Some('/')) => {
453 state = if depth == 1 {
454 State::Code
455 } else {
456 State::Block(depth - 1)
457 };
458 out.push_str(" ");
459 i += 2;
460 continue;
461 }
462 ('\n', _) => out.push('\n'),
463 _ => out.push(' '),
464 },
465 State::Str => match (c, next) {
466 ('\\', Some(_)) => {
467 out.push_str(" ");
468 i += 2;
469 continue;
470 }
471 ('"', _) => {
472 state = State::Code;
473 out.push('"');
474 }
475 ('\n', _) => {
476 state = State::Code;
478 out.push('\n');
479 }
480 _ => out.push(' '),
481 },
482 State::MultiStr => {
483 if c == '"' && chars.get(i + 1) == Some(&'"') && chars.get(i + 2) == Some(&'"') {
484 state = State::Code;
485 out.push_str("\"\"\"");
486 i += 3;
487 continue;
488 }
489 out.push(if c == '\n' { '\n' } else { ' ' });
490 }
491 }
492 i += 1;
493 }
494 out
495}
496
497pub(crate) fn generate_bindings(root: &Path, out: &Path) -> Result<(), String> {
506 let manifest = std::fs::read_to_string(root.join("Cargo.toml"))
507 .map_err(|e| format!("day-build: reading Cargo.toml: {e}"))?;
508 let (packages, has_piece_dep) = local_packages_from_manifest(&manifest)
509 .map_err(|e| format!("day-build: Cargo.toml: {e}"))?;
510
511 let code = if packages.is_empty() {
512 String::from(
513 "// Generated by day-build. No local SwiftPM packages are declared in\n\
514 // [package.metadata.day.ios/macos] swift-packages (docs/swiftui.md).\n",
515 )
516 } else if !has_piece_dep {
517 println!(
520 "cargo:warning=day-build: local Swift packages are declared but day-piece-swiftui \
521 is not a dependency — skipping the SwiftUI bindings (docs/swiftui.md)"
522 );
523 String::from(
524 "// Generated by day-build. SwiftUI bindings skipped: day-piece-swiftui is not a\n\
525 // dependency of this crate (docs/swiftui.md).\n",
526 )
527 } else {
528 let mut scans = Vec::new();
529 for rel in &packages {
530 let dir = root.join(rel);
531 println!("cargo:rerun-if-changed={rel}/Sources");
533 println!("cargo:rerun-if-changed={rel}/Package.swift");
534 let scan = scan_package(&dir)?;
535 for (view, reason) in &scan.skipped {
536 println!("cargo:warning=day-build: swiftui: {view} not exported — {reason}");
537 }
538 scans.push((rel.clone(), scan));
539 }
540 let mut names: Vec<(String, String)> = scans
542 .iter()
543 .flat_map(|(_, s)| s.views.iter().map(|v| (v.name.clone(), v.piece_name())))
544 .collect();
545 names.sort();
546 for pair in names.windows(2) {
547 if pair[0].0 == pair[1].0 {
548 return Err(format!(
549 "day-build: two exported views are both named `{}` ({} and {}) — \
550 `crate::swiftui` is flat, rename one",
551 pair[0].0, pair[0].1, pair[1].1
552 ));
553 }
554 }
555 render_bindings(&scans)
556 };
557 std::fs::write(out.join("day_swiftui.rs"), code)
558 .map_err(|e| format!("day-build: writing day_swiftui.rs: {e}"))
559}
560
561fn local_packages_from_manifest(manifest: &str) -> Result<(Vec<String>, bool), String> {
564 let table: toml::Table = manifest.parse().map_err(|e| format!("{e}"))?;
565 let mut packages: Vec<String> = Vec::new();
566 for key in ["ios", "macos"] {
567 let entries = table
568 .get("package")
569 .and_then(|v| v.get("metadata"))
570 .and_then(|v| v.get("day"))
571 .and_then(|v| v.get(key))
572 .and_then(|v| v.get("swift-packages"))
573 .and_then(|v| v.as_array());
574 for entry in entries.into_iter().flatten() {
575 if let Some(path) = entry.get("path").and_then(|p| p.as_str())
576 && !packages.iter().any(|p| p == path)
577 {
578 packages.push(path.to_string());
579 }
580 }
581 }
582 let has_piece_dep = ["dependencies", "build-dependencies"].iter().any(|t| {
583 table
584 .get(*t)
585 .and_then(|d| d.as_table())
586 .is_some_and(|d| d.contains_key("day-piece-swiftui"))
587 });
588 Ok((packages, has_piece_dep))
589}
590
591pub fn render_bindings(packages: &[(String, SwiftScan)]) -> String {
599 let mut out = String::from(
601 "// Generated by day-build from the local SwiftPM packages declared in\n\
602 // [package.metadata.day.ios/macos] swift-packages (docs/swiftui.md). Do not edit.\n",
603 );
604 for (pkg, scan) in packages {
605 for view in &scan.views {
606 let generics: Vec<String> = (0..view.params.len()).map(|i| format!("M{i}")).collect();
607 let generic_list = if generics.is_empty() {
608 String::new()
609 } else {
610 format!("<{}>", generics.join(", "))
611 };
612 let args: Vec<String> = view
613 .params
614 .iter()
615 .enumerate()
616 .map(|(i, p)| {
617 format!(
618 "{}: impl day_piece_swiftui::IntoReactive<{}, M{i}>",
619 p.key(),
620 p.ty.rust()
621 )
622 })
623 .collect();
624 let sig_doc: Vec<String> = view
625 .params
626 .iter()
627 .map(|p| format!("{}: {}", p.key(), p.ty.swift()))
628 .collect();
629 out.push_str(&format!(
630 "\n/// `{}` from `{}/{}` — `public init({})`.\n\
631 /// Each argument accepts a constant, a `Signal`, or a closure; reactive values\n\
632 /// re-invoke the view's initializer live (`@State` is preserved).\n\
633 #[allow(non_snake_case)]\n\
634 pub fn {}{generic_list}({}) -> day_piece_swiftui::SwiftUi {{\n",
635 view.piece_name(),
636 pkg,
637 view.source,
638 sig_doc.join(", "),
639 view.name,
640 args.join(", "),
641 ));
642 for p in &view.params {
643 out.push_str(&format!(
644 " let {k} = day_piece_swiftui::IntoReactive::into_reactive({k});\n",
645 k = p.key()
646 ));
647 }
648 let fields: Vec<String> = view
649 .params
650 .iter()
651 .map(|p| {
652 let k = p.key();
653 let value = match p.ty {
654 SwiftType::String => {
655 format!("day_piece_swiftui::json::string(&{k}.get())")
656 }
657 SwiftType::Int => format!("day_piece_swiftui::json::int({k}.get())"),
658 SwiftType::Double => format!("day_piece_swiftui::json::float({k}.get())"),
659 SwiftType::Bool => format!("day_piece_swiftui::json::boolean({k}.get())"),
660 };
661 format!("(\"{k}\", {value})")
662 })
663 .collect();
664 if fields.is_empty() {
665 out.push_str(&format!(
666 " day_piece_swiftui::swiftui(\"{}\")\n}}\n",
667 view.piece_name()
668 ));
669 } else {
670 out.push_str(&format!(
671 " day_piece_swiftui::swiftui(\"{}\").params(move || {{\n\
672 \x20 day_piece_swiftui::json::object(&[\n {},\n ])\n\
673 \x20 }})\n}}\n",
674 view.piece_name(),
675 fields.join(",\n "),
676 ));
677 }
678 }
679 }
680 out
681}
682
683pub fn render_glue(packages: &[(String, SwiftScan)]) -> String {
688 let mut modules: Vec<&str> = packages
689 .iter()
690 .flat_map(|(_, s)| s.views.iter().map(|v| v.module.as_str()))
691 .collect();
692 modules.sort();
693 modules.dedup();
694
695 let mut out = String::from(
696 "// Generated by `day build` from local SwiftPM packages (docs/swiftui.md). Do not edit.\n\
697 import SwiftUI\n",
698 );
699 for m in &modules {
700 out.push_str(&format!("import {m}\n"));
701 }
702 for (pkg, scan) in packages {
703 for view in &scan.views {
704 let class = view.class_name();
705 out.push_str(&format!("\n// {}/{}\n@objc({class})\n", pkg, view.source));
706 if view.params.is_empty() {
707 out.push_str(&format!(
708 "final class {class}: DaySwiftUIProvider {{\n\
709 \x20 override func body(_ params: String?) -> AnyView {{\n\
710 \x20 AnyView({}())\n }}\n}}\n",
711 view.name
712 ));
713 continue;
714 }
715 let fields: String = view
716 .params
717 .iter()
718 .map(|p| format!(" var {}: {}\n", p.key(), p.ty.swift()))
719 .collect();
720 let args: Vec<String> = view
721 .params
722 .iter()
723 .map(|p| match &p.label {
724 Some(label) => format!("{label}: p.{}", p.key()),
725 None => format!("p.{}", p.key()),
726 })
727 .collect();
728 out.push_str(&format!(
729 "final class {class}: DaySwiftUIProvider {{\n\
730 \x20 struct Params: Decodable {{\n{fields} }}\n\
731 \x20 override func body(_ params: String?) -> AnyView {{\n\
732 \x20 guard let data = params?.data(using: .utf8),\n\
733 \x20 let p = try? JSONDecoder().decode(Params.self, from: data)\n\
734 \x20 else {{ return DaySwiftUI.errorView(\"{name}\") }}\n\
735 \x20 return AnyView({view}({args}))\n }}\n}}\n",
736 name = view.piece_name(),
737 view = view.name,
738 args = args.join(", "),
739 ));
740 }
741 }
742 out
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 fn scan(src: &str) -> SwiftScan {
750 let mut s = SwiftScan::default();
751 scan_source("Mod", "Sources/Mod/File.swift", src, &mut s);
752 s
753 }
754
755 #[test]
756 fn a_public_view_with_a_supported_init_is_exported() {
757 let s = scan(
758 "import SwiftUI\n\
759 public struct MyView: View {\n\
760 let title: String\n\
761 public init(title: String, count: Int, ratio: Double, on: Bool) {\n\
762 self.title = title\n\
763 }\n\
764 public var body: some View { Text(title) }\n\
765 }\n",
766 );
767 assert_eq!(s.skipped, vec![]);
768 assert_eq!(s.views.len(), 1);
769 let v = &s.views[0];
770 assert_eq!(v.piece_name(), "Mod.MyView");
771 assert_eq!(v.class_name(), "DayView_Mod_MyView");
772 let keys: Vec<&str> = v.params.iter().map(|p| p.key()).collect();
773 assert_eq!(keys, ["title", "count", "ratio", "on"]);
774 assert_eq!(v.params[1].ty, SwiftType::Int);
775 }
776
777 #[test]
778 fn internal_and_non_view_structs_are_ignored_silently() {
779 let s = scan(
780 "struct Helper: View { var body: some View { Text(\"x\") } }\n\
781 public struct Model: Codable, Equatable { public init() {} }\n",
782 );
783 assert_eq!(s.views, vec![]);
784 assert_eq!(s.skipped, vec![]);
785 }
786
787 #[test]
788 fn unsupported_inits_are_skipped_with_a_reason() {
789 let s = scan(
790 "public struct A: View { public init(m: MyModel) {} }\n\
791 public struct B: View { public init(n: Int = 3) {} }\n\
792 public struct C: View { let x = 1 }\n\
793 public struct D<T>: View { public init() {} }\n",
794 );
795 assert_eq!(s.views, vec![]);
796 let names: Vec<&str> = s.skipped.iter().map(|(n, _)| n.as_str()).collect();
797 assert_eq!(names, ["Mod.A", "Mod.B", "Mod.C", "Mod.D"]);
798 assert!(s.skipped[0].1.contains("MyModel"));
799 assert!(s.skipped[1].1.contains("default"));
800 assert!(s.skipped[2].1.contains("no public init"));
801 assert!(s.skipped[3].1.contains("generic"));
802 }
803
804 #[test]
805 fn unlabeled_and_two_name_parameters_parse() {
806 let s = scan(
807 "public struct V: View {\n\
808 public init(_ value: String, with count: Int) {}\n\
809 }\n",
810 );
811 let v = &s.views[0];
812 assert_eq!(v.params[0].label, None);
813 assert_eq!(v.params[0].key(), "value");
814 assert_eq!(v.params[1].label.as_deref(), Some("with"));
815 assert_eq!(v.params[1].key(), "with");
816 assert_eq!(v.params[1].name, "count");
817 }
818
819 #[test]
820 fn braces_inside_comments_and_strings_do_not_confuse_the_depth() {
821 let s = scan(
822 "// a stray { in a comment\n\
823 /* and { another /* nested { */ } */\n\
824 let sample = \"{ not a brace }\"\n\
825 let big = \"\"\"\n{ multi } \"line\"\n\"\"\"\n\
826 public struct V: View { public init() {} }\n",
827 );
828 assert_eq!(s.views.len(), 1);
829 assert!(s.views[0].params.is_empty());
830 }
831
832 #[test]
833 fn nested_public_structs_are_not_top_level() {
834 let s = scan(
835 "public enum NS {\n\
836 public struct Inner: View { public init(t: String) {} }\n\
837 }\n",
838 );
839 assert_eq!(s.views, vec![]);
840 assert_eq!(s.skipped, vec![]);
841 }
842
843 #[test]
844 fn the_first_public_init_is_the_contract() {
845 let s = scan(
846 "public struct V: View {\n\
847 public init(model: Thing) {}\n\
848 public init(title: String) {}\n\
849 }\n",
850 );
851 assert_eq!(s.views, vec![]);
854 assert!(s.skipped[0].1.contains("Thing"));
855 }
856
857 #[test]
858 fn bindings_render_typed_reactive_constructors() {
859 let mut s = SwiftScan::default();
860 scan_source(
861 "Mod",
862 "Sources/Mod/V.swift",
863 "public struct MyView: View { public init(title: String, count: Int) {} }\n\
864 public struct Plain: View { public init() {} }\n",
865 &mut s,
866 );
867 let code = render_bindings(&[("swiftui".into(), s)]);
868 assert!(code.contains("pub fn MyView<M0, M1>("));
869 assert!(code.contains("title: impl day_piece_swiftui::IntoReactive<String, M0>"));
870 assert!(code.contains("count: impl day_piece_swiftui::IntoReactive<i64, M1>"));
871 assert!(code.contains("swiftui(\"Mod.MyView\").params(move ||"));
872 assert!(code.contains("(\"count\", day_piece_swiftui::json::int(count.get()))"));
873 assert!(code.contains("pub fn Plain() -> day_piece_swiftui::SwiftUi"));
874 assert!(!code.contains("Plain\").params"));
875 }
876
877 #[test]
878 fn glue_renders_a_provider_per_view() {
879 let mut s = SwiftScan::default();
880 scan_source(
881 "Mod",
882 "Sources/Mod/V.swift",
883 "public struct MyView: View { public init(_ text: String, count: Int) {} }\n",
884 &mut s,
885 );
886 let glue = render_glue(&[("swiftui".into(), s)]);
887 assert!(glue.contains("import Mod"));
888 assert!(glue.contains("@objc(DayView_Mod_MyView)"));
889 assert!(glue.contains("var text: String"));
890 assert!(glue.contains("AnyView(MyView(p.text, count: p.count))"));
891 assert!(glue.contains("DaySwiftUI.errorView(\"Mod.MyView\")"));
892 }
893
894 #[test]
895 fn local_packages_derive_from_the_manifest() {
896 let manifest = r#"
897[package]
898name = "showcase"
899
900[dependencies]
901day = { git = "https://github.com/daybrite/day.git" }
902day-piece-swiftui = { git = "https://github.com/daybrite/day.git" }
903
904[package.metadata.day.ios]
905swift-packages = [{ path = "swiftui" }, { url = "https://example.com/pkg", from = "1.0.0" }]
906platform = "16.0"
907
908[package.metadata.day.macos]
909swift-packages = [{ path = "swiftui" }]
910"#;
911 let (packages, has_dep) = local_packages_from_manifest(manifest).expect("parses");
912 assert_eq!(packages, vec!["swiftui"]);
914 assert!(has_dep);
915
916 let bare = "[package]\nname = \"app\"\n[dependencies]\nday = \"1\"\n";
917 let (packages, has_dep) = local_packages_from_manifest(bare).expect("parses");
918 assert_eq!(packages, Vec::<String>::new());
919 assert!(!has_dep);
920 }
921
922 #[test]
923 fn duplicate_view_names_across_modules_error() {
924 let mut s = SwiftScan::default();
926 scan_source(
927 "A",
928 "a.swift",
929 "public struct V: View { public init() {} }",
930 &mut s,
931 );
932 scan_source(
933 "B",
934 "b.swift",
935 "public struct V: View { public init() {} }",
936 &mut s,
937 );
938 s.views
939 .sort_by(|a, b| (&a.module, &a.name).cmp(&(&b.module, &b.name)));
940 assert_eq!(s.views.len(), 2);
941 assert_eq!(s.views[0].name, s.views[1].name);
942 }
943}