Skip to main content

day_build/
swiftui.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! SwiftUI view scanning + codegen for embedded SwiftPM packages (docs/swiftui.md).
5//!
6//! An app (or piece crate) can point `[package.metadata.day.ios/macos] swift-packages` at a
7//! **local** SwiftPM package. This module is the single grammar both consumers share:
8//!
9//! - the app's `build.rs` ([`crate::generate_resources`]) scans the package and emits typed Rust
10//!   bindings to `$OUT_DIR/day_swiftui.rs` (surfaced as `crate::swiftui::MyView(…)`), and
11//! - `day build` (day-cli) runs the same scan and emits the Swift provider glue that wraps each
12//!   view in a hosting view (staged into the generated `DayPieces` module).
13//!
14//! The scan is a **text parse of a documented subset** — deliberately not a Swift compiler:
15//! it must run on any host, with no Swift toolchain, from a plain `cargo build` (DESIGN §17.5).
16//! A view is exported when it is a **top-level, non-generic `public struct` whose declaration
17//! names `View` in its inheritance clause**, and its **first `public init`** has only supported
18//! parameter types (`String`, `Int`, `Double`, `Bool`; no defaults, no attributes, no variadics).
19//! Anything else is skipped with a reason. A mis-parse cannot ship silently: the generated Swift
20//! glue calls the real initializer, so the Swift compiler validates every signature this parser
21//! extracted.
22
23use std::path::{Path, PathBuf};
24
25/// A parameter of an exported view's initializer.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SwiftParam {
28    /// The external argument label, or `None` for `_` (unlabeled). The JSON key and the Rust
29    /// argument use [`SwiftParam::key`] either way.
30    pub label: Option<String>,
31    /// The internal parameter name.
32    pub name: String,
33    pub ty: SwiftType,
34}
35
36impl SwiftParam {
37    /// The name shared by the JSON params object and the generated Rust argument: the external
38    /// label when there is one, else the internal name.
39    pub fn key(&self) -> &str {
40        self.label.as_deref().unwrap_or(&self.name)
41    }
42}
43
44/// The parameter types the bridge can marshal (JSON params → `Decodable` → the Swift init).
45#[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    /// The Swift spelling (glue `Decodable` fields).
64    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    /// The Rust spelling (generated binding arguments).
73    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/// One exported view: `swiftui("<module>.<name>")` on the Rust side, class
84/// `DayView_<module>_<name>` on the Swift side.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SwiftView {
87    /// The SwiftPM target (module) name — the `Sources/<Module>` directory.
88    pub module: String,
89    /// The struct name.
90    pub name: String,
91    /// The first public init's parameters, in declaration order.
92    pub params: Vec<SwiftParam>,
93    /// Package-relative source path (doc comments in the generated code).
94    pub source: String,
95}
96
97impl SwiftView {
98    /// The canonical piece name (`swiftui(...)` argument): `Module.View`.
99    pub fn piece_name(&self) -> String {
100        format!("{}.{}", self.module, self.name)
101    }
102    /// The Objective-C class name of the generated provider: `DayView_Module_View`.
103    pub fn class_name(&self) -> String {
104        format!("DayView_{}_{}", self.module, self.name)
105    }
106}
107
108/// A scanned package: the exported views plus everything that looked like a public View but was
109/// skipped (surfaced as build warnings so a missing binding is never a silent mystery).
110#[derive(Debug, Default, Clone, PartialEq, Eq)]
111pub struct SwiftScan {
112    pub views: Vec<SwiftView>,
113    /// `(Module.Name, reason)` — public `View` structs the subset could not export.
114    pub skipped: Vec<(String, String)>,
115}
116
117/// Scan a local SwiftPM package (conventional layout: `Sources/<Module>/**/*.swift`) for
118/// exportable public SwiftUI views. Views are sorted by `(module, name)` for deterministic output.
119pub 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    // Duplicate simple names would collide in the flat `crate::swiftui` module — fail loudly with
166    // the fix (rename one view) rather than silently shadowing.
167    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
192/// Scan one source file for top-level exported views (the pure, testable core).
193pub 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/// Parse a `public struct` starting at `start`. Returns the outcome and the index just past the
220/// struct's closing brace (`Err(None)` = not a `View`, nothing to report).
221#[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    // The inheritance/where clause runs to the struct's opening brace.
237    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    // The first `public init` in the struct body is the exported constructor (docs/swiftui.md).
251    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
287/// Does the inheritance clause name `View` (as a whole word, possibly qualified `SwiftUI.View`)?
288fn 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
296/// Parse an init's parameter list (the text between its parentheses).
297fn 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
335/// Split on commas at nesting depth zero (parens/brackets/angles).
336fn 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
355/// Index just past the brace matching the `{` at `open`.
356fn 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
373/// Index of the paren matching the `(` at `open`.
374fn 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
391/// Blank out comments and string-literal contents (keeping newlines) so declaration scanning
392/// can't be fooled by braces or keywords inside them. Handles `//`, nested `/* */`, `"…"` with
393/// escapes, and `"""` multiline strings; interpolation contents are blanked with the string
394/// (a nested quote inside `\(…)` is outside the subset — the glue compile catches any fallout).
395fn 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                    // Unterminated line — bail back to code so one bad literal can't eat the file.
477                    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
497// ===========================================================================
498// build.rs entry — derive the package list from Cargo.toml, scan, emit the bindings
499// ===========================================================================
500
501/// The build-script half (called from [`crate::generate_resources`]): read the crate's own
502/// `[package.metadata.day.ios/macos].swift-packages` for local `path` entries, scan each package,
503/// and write `$OUT_DIR/day_swiftui.rs`. Always writes a valid module (empty when nothing is
504/// declared), so `pub mod swiftui { include!(…) }` is safe to keep in lib.rs unconditionally.
505pub(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        // The bindings call day_piece_swiftui::* — without the dependency they cannot compile,
518        // so skip them loudly rather than failing the build with a confusing resolver error.
519        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            // Directory tracking is recursive, so an added/renamed view regenerates the bindings.
532            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        // Duplicate simple names across PACKAGES collide in the flat module too.
541        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
561/// Extract the deduped local `swift-packages` paths from both Apple metadata tables, plus whether
562/// `day-piece-swiftui` is a declared dependency (the bindings need its crate).
563fn 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
591// ===========================================================================
592// Codegen — the Rust bindings (build.rs) and the Swift provider glue (day-cli)
593// ===========================================================================
594
595/// Render `$OUT_DIR/day_swiftui.rs` — one typed constructor per exported view, mirroring the Swift
596/// identity verbatim (`crate::swiftui::MyView(…)`). `packages` pairs each package's display path
597/// (for doc comments) with its scan. Always renders a valid module, empty when nothing is exported.
598pub fn render_bindings(packages: &[(String, SwiftScan)]) -> String {
599    // No inner attributes: the file is `include!`d inside a `pub mod`, where they cannot appear.
600    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
683/// Render the Swift provider glue for one crate's local packages: an `@objc(DayView_Module_View)`
684/// [`DaySwiftUIProvider`] subclass per view, decoding the JSON params into the real initializer.
685/// The file joins the generated `DayPieces` module (where `DaySwiftUIProvider` also lives), so the
686/// only imports are SwiftUI and the scanned modules themselves.
687pub 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        // The FIRST public init is unsupported, so the view is skipped — a documented rule, so a
852        // reordered overload can't silently switch which constructor the binding calls.
853        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        // Deduped across the two tables; the url entry is not a scan root.
913        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        // Exercised through scan_package's post-sort check; simulate its input here.
925        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}