Skip to main content

weaveffi_core/
pkg.rs

1//! Shared package-identity resolution.
2//!
3//! A single `package:` block in the IDL ([`weaveffi_ir::ir::Package`]) is the
4//! source of truth for the name, version, and metadata stamped into every
5//! ecosystem manifest (`package.json`, `pyproject.toml`, `*.gemspec`,
6//! `*.csproj`, `pubspec.yaml`, `Package.swift`, `build.gradle`, `go.mod`).
7//!
8//! This module centralizes the *resolution* rules — precedence and defaults —
9//! so all eleven generators agree on the package identity instead of each one
10//! hardcoding `weaveffi` / `0.1.0`. Generators read [`resolve`] in their
11//! manifest code and map the [`ResolvedPackage`] fields onto whatever their
12//! ecosystem's manifest format requires.
13
14use weaveffi_ir::ir::Api;
15
16/// Fallback package version when the IDL omits `package.version`.
17pub const DEFAULT_VERSION: &str = "0.1.0";
18
19/// Fallback package name when the IDL omits `package.name` and no per-target
20/// override or input basename is available.
21pub const DEFAULT_NAME: &str = "weaveffi";
22
23/// Package identity resolved for one generator/manifest.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ResolvedPackage {
26    /// Distribution name (npm/PyPI/gem/NuGet/pub/etc.).
27    pub name: String,
28    /// Semantic version stamped into the manifest.
29    pub version: String,
30    pub description: Option<String>,
31    pub license: Option<String>,
32    pub authors: Vec<String>,
33    pub homepage: Option<String>,
34    pub repository: Option<String>,
35}
36
37impl ResolvedPackage {
38    /// Human-readable description, or a generated default when the IDL omits it.
39    pub fn description_or_default(&self) -> String {
40        self.description
41            .clone()
42            .filter(|s| !s.is_empty())
43            .unwrap_or_else(|| format!("{} bindings generated by WeaveFFI", self.name))
44    }
45
46    /// The `name` rewritten so it is a legal lower-snake identifier (e.g. a
47    /// Python import package or a Ruby `require` path): non-alphanumerics
48    /// collapse to `_`. `"my-kv.store"` → `"my_kv_store"`.
49    pub fn ident_name(&self) -> String {
50        sanitize_ident(&self.name)
51    }
52
53    /// The `name` rewritten as an UpperCamelCase identifier suitable for a
54    /// language-level module or namespace (Ruby `module`, .NET `namespace`,
55    /// Swift module, Dart class prefix). `"my-kv.store"` → `"MyKvStore"`.
56    pub fn module_name(&self) -> String {
57        pascal_ident(&self.name)
58    }
59}
60
61/// UpperCamelCase identifier-safe form of an arbitrary package name. Word
62/// boundaries are any run of non-alphanumerics (and existing camel humps are
63/// preserved). `"my-kv.store"` → `"MyKvStore"`, `"kvstore"` → `"Kvstore"`.
64pub fn pascal_ident(name: &str) -> String {
65    let mut out = String::with_capacity(name.len());
66    let mut start_word = true;
67    for ch in name.chars() {
68        if ch.is_ascii_alphanumeric() {
69            if start_word {
70                out.push(ch.to_ascii_uppercase());
71            } else {
72                out.push(ch);
73            }
74            start_word = false;
75        } else {
76            start_word = true;
77        }
78    }
79    if out.is_empty() {
80        pascal_ident(DEFAULT_NAME)
81    } else {
82        out
83    }
84}
85
86/// Lower-case identifier-safe form of an arbitrary package name.
87pub fn sanitize_ident(name: &str) -> String {
88    let mut out = String::with_capacity(name.len());
89    let mut prev_us = false;
90    for ch in name.chars() {
91        if ch.is_ascii_alphanumeric() {
92            out.push(ch.to_ascii_lowercase());
93            prev_us = false;
94        } else if !prev_us && !out.is_empty() {
95            out.push('_');
96            prev_us = true;
97        }
98    }
99    let trimmed = out.trim_end_matches('_');
100    if trimmed.is_empty() {
101        DEFAULT_NAME.to_string()
102    } else {
103        trimmed.to_string()
104    }
105}
106
107/// Strip directory and extension from an IDL basename to use as a fallback
108/// package name. `"path/kvstore.yml"` → `"kvstore"`, `None`/empty →
109/// [`DEFAULT_NAME`].
110pub fn name_from_basename(basename: Option<&str>) -> String {
111    basename
112        .and_then(|b| b.rsplit(['/', '\\']).next())
113        .map(|b| b.split('.').next().unwrap_or(b))
114        .filter(|s| !s.is_empty())
115        .unwrap_or(DEFAULT_NAME)
116        .to_string()
117}
118
119/// Resolve package identity for a generator.
120///
121/// Name precedence (first non-empty wins):
122/// 1. explicit per-target `name_override` (e.g. `python.package_name`),
123/// 2. `api.package.name`,
124/// 3. the IDL file stem (`input_basename`),
125/// 4. [`DEFAULT_NAME`].
126///
127/// Version: `api.package.version` → [`DEFAULT_VERSION`]. All other metadata is
128/// taken verbatim from the `package:` block (absent → `None`/empty).
129pub fn resolve(
130    api: &Api,
131    name_override: Option<&str>,
132    input_basename: Option<&str>,
133) -> ResolvedPackage {
134    let pkg = api.package.as_ref();
135    let name = name_override
136        .map(str::trim)
137        .filter(|s| !s.is_empty())
138        .map(str::to_string)
139        .or_else(|| {
140            pkg.map(|p| p.name.trim().to_string())
141                .filter(|s| !s.is_empty())
142        })
143        .unwrap_or_else(|| name_from_basename(input_basename));
144    let version = pkg
145        .map(|p| p.version.trim().to_string())
146        .filter(|s| !s.is_empty())
147        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
148    ResolvedPackage {
149        name,
150        version,
151        description: pkg
152            .and_then(|p| p.description.clone())
153            .filter(|s| !s.is_empty()),
154        license: pkg
155            .and_then(|p| p.license.clone())
156            .filter(|s| !s.is_empty()),
157        authors: pkg.map(|p| p.authors.clone()).unwrap_or_default(),
158        homepage: pkg
159            .and_then(|p| p.homepage.clone())
160            .filter(|s| !s.is_empty()),
161        repository: pkg
162            .and_then(|p| p.repository.clone())
163            .filter(|s| !s.is_empty()),
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use weaveffi_ir::ir::Package;
171
172    fn api_with(pkg: Option<Package>) -> Api {
173        Api {
174            version: "0.3.0".into(),
175            package: pkg,
176            modules: vec![],
177            generators: None,
178        }
179    }
180
181    fn full_pkg() -> Package {
182        Package {
183            name: "kvstore".into(),
184            version: "1.2.0".into(),
185            description: Some("KV store".into()),
186            license: Some("MIT".into()),
187            authors: vec!["Ada".into()],
188            homepage: Some("https://example.com".into()),
189            repository: Some("https://github.com/x/kvstore".into()),
190        }
191    }
192
193    #[test]
194    fn package_block_drives_identity() {
195        let api = api_with(Some(full_pkg()));
196        let r = resolve(&api, None, Some("ignored.yml"));
197        assert_eq!(r.name, "kvstore");
198        assert_eq!(r.version, "1.2.0");
199        assert_eq!(r.license.as_deref(), Some("MIT"));
200        assert_eq!(r.authors, vec!["Ada".to_string()]);
201    }
202
203    #[test]
204    fn target_override_beats_package_name() {
205        let api = api_with(Some(full_pkg()));
206        let r = resolve(&api, Some("kvstore_py"), Some("kvstore.yml"));
207        assert_eq!(r.name, "kvstore_py");
208        // Version still comes from the package block.
209        assert_eq!(r.version, "1.2.0");
210    }
211
212    #[test]
213    fn falls_back_to_file_stem_then_default() {
214        let api = api_with(None);
215        let r = resolve(&api, None, Some("path/to/contacts.yml"));
216        assert_eq!(r.name, "contacts");
217        assert_eq!(r.version, DEFAULT_VERSION);
218
219        let r2 = resolve(&api, None, None);
220        assert_eq!(r2.name, DEFAULT_NAME);
221    }
222
223    #[test]
224    fn description_default_is_generated() {
225        let api = api_with(None);
226        let r = resolve(&api, Some("widgets"), None);
227        assert_eq!(
228            r.description_or_default(),
229            "widgets bindings generated by WeaveFFI"
230        );
231    }
232
233    #[test]
234    fn ident_name_sanitizes() {
235        assert_eq!(sanitize_ident("my-kv.store"), "my_kv_store");
236        assert_eq!(sanitize_ident("Kvstore"), "kvstore");
237        assert_eq!(sanitize_ident("--"), DEFAULT_NAME);
238    }
239
240    #[test]
241    fn pascal_ident_upper_camels() {
242        assert_eq!(pascal_ident("my-kv.store"), "MyKvStore");
243        assert_eq!(pascal_ident("kvstore"), "Kvstore");
244        assert_eq!(pascal_ident("contacts"), "Contacts");
245        assert_eq!(pascal_ident("--"), "Weaveffi");
246    }
247}