Skip to main content

weaveffi_model/
pkg.rs

1//! Package identity: the one source of every name a generated package uses.
2//!
3//! The `[package]` table of a project's `weaveffi.toml` ([`Package`]) declares
4//! what the library is called and how it is published. The CLI resolves it
5//! once into an [`Identity`] and attaches it to the [`ResolvedApi`], so the C
6//! symbol prefix, the native library name, and every ecosystem's package,
7//! module, and namespace name derive from the same values. Nothing a
8//! generator emits is named after WeaveFFI itself, so any number of
9//! WeaveFFI-built libraries can coexist in one process and one application.
10//!
11//! [`ResolvedApi`]: crate::ResolvedApi
12
13use serde::{Deserialize, Serialize};
14
15/// Fallback package version when the project omits `package.version`.
16pub const DEFAULT_VERSION: &str = "0.1.0";
17
18/// Fallback package name for an API that was never given an identity (unit
19/// tests and other in-memory uses). The CLI always derives a real one.
20pub const DEFAULT_NAME: &str = "api";
21
22/// The `[package]` table of `weaveffi.toml`.
23///
24/// Every field is optional in the file; the CLI fills the gaps from the
25/// producer's `Cargo.toml` (for a Rust producer) or the input file name (for
26/// an IDL) before calling [`Identity::new`].
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct Package {
30    /// Distribution name used by every ecosystem (npm, PyPI, NuGet, and so
31    /// on) unless a target's own configuration overrides it.
32    pub name: Option<String>,
33    /// Semantic version stamped into every manifest.
34    pub version: Option<String>,
35    /// Short package description.
36    pub description: Option<String>,
37    /// License identifier, typically an SPDX expression.
38    pub license: Option<String>,
39    /// Package authors.
40    pub authors: Vec<String>,
41    /// Project homepage URL.
42    pub homepage: Option<String>,
43    /// Source repository URL.
44    pub repository: Option<String>,
45    /// C symbol prefix for an IDL-defined library. Defaults to the snake-case
46    /// package name. A Rust producer's prefix is always its crate name, so
47    /// setting this for a `.rs` input is an error.
48    pub c_prefix: Option<String>,
49    /// Base name of the native library (`lib{library}.so`, `{library}.dll`)
50    /// for an IDL-defined library. Defaults to the snake-case package name.
51    /// A Rust producer's library is always its crate's library name.
52    pub library: Option<String>,
53}
54
55/// The resolved identity of the library being bound.
56///
57/// Every field is final: generators read names from here (and from the
58/// target's own configuration overrides) and never invent their own.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
60pub struct Identity {
61    /// Distribution name as published (may contain `-` or `.`).
62    pub name: String,
63    /// C symbol prefix: a lower-snake C identifier that begins every exported
64    /// symbol and every C type name (`{prefix}_error`, `{prefix}_kv_get`).
65    pub prefix: String,
66    /// Native library base name: the producer is `lib{library}.so`,
67    /// `lib{library}.dylib`, or `{library}.dll`.
68    pub library: String,
69    /// Semantic version.
70    pub version: String,
71    /// Short description, if declared.
72    pub description: Option<String>,
73    /// License identifier, if declared.
74    pub license: Option<String>,
75    /// Package authors.
76    pub authors: Vec<String>,
77    /// Project homepage URL, if declared.
78    pub homepage: Option<String>,
79    /// Source repository URL, if declared.
80    pub repository: Option<String>,
81}
82
83impl Default for Identity {
84    fn default() -> Self {
85        Self::named(DEFAULT_NAME)
86    }
87}
88
89impl Identity {
90    /// An identity with only a name: the prefix and library are the
91    /// snake-case name and every metadata field is unset.
92    #[must_use]
93    pub fn named(name: &str) -> Self {
94        Self::new(name, &Package::default())
95    }
96
97    /// Resolve `package` (whose own `name`, if set, wins over `name`) into a
98    /// complete identity. The prefix and library default to the snake-case
99    /// form of the resolved name.
100    #[must_use]
101    pub fn new(name: &str, package: &Package) -> Self {
102        let name = non_empty(package.name.as_ref()).unwrap_or_else(|| name.trim().to_string());
103        let name = if name.is_empty() {
104            DEFAULT_NAME.to_string()
105        } else {
106            name
107        };
108        let snake = c_ident(&name);
109        Self {
110            prefix: non_empty(package.c_prefix.as_ref())
111                .map(|p| c_ident(&p))
112                .unwrap_or_else(|| snake.clone()),
113            library: non_empty(package.library.as_ref()).unwrap_or(snake),
114            version: non_empty(package.version.as_ref())
115                .unwrap_or_else(|| DEFAULT_VERSION.to_string()),
116            description: non_empty(package.description.as_ref()),
117            license: non_empty(package.license.as_ref()),
118            authors: package.authors.clone(),
119            homepage: non_empty(package.homepage.as_ref()),
120            repository: non_empty(package.repository.as_ref()),
121            name,
122        }
123    }
124
125    /// The uppercase prefix used for C preprocessor macros (`KVSTORE_API`,
126    /// `KVSTORE_ABI_VERSION`).
127    #[must_use]
128    pub fn macro_prefix(&self) -> String {
129        self.prefix.to_ascii_uppercase()
130    }
131
132    /// The name as a lower-snake identifier (a Python import package, a Ruby
133    /// `require` path, a Dart package). `"my-kv.store"` becomes
134    /// `"my_kv_store"`.
135    #[must_use]
136    pub fn snake_name(&self) -> String {
137        c_ident(&self.name)
138    }
139
140    /// The name as an UpperCamelCase identifier (a Swift module, a .NET
141    /// namespace, a Ruby module). `"my-kv.store"` becomes `"MyKvStore"`.
142    #[must_use]
143    pub fn pascal_name(&self) -> String {
144        pascal_ident(&self.name)
145    }
146
147    /// The description, or a generated one when unset.
148    #[must_use]
149    pub fn description_or_default(&self) -> String {
150        self.description
151            .clone()
152            .unwrap_or_else(|| format!("Native bindings for {}", self.name))
153    }
154
155    /// The environment variable a generated loader consults for an explicit
156    /// library path (`KVSTORE_LIBRARY`).
157    #[must_use]
158    pub fn library_env_var(&self) -> String {
159        format!("{}_LIBRARY", self.macro_prefix())
160    }
161
162    /// Platform file names for the native library: `(macOS, Linux, Windows)`.
163    #[must_use]
164    pub fn library_files(&self) -> (String, String, String) {
165        let l = &self.library;
166        (
167            format!("lib{l}.dylib"),
168            format!("lib{l}.so"),
169            format!("{l}.dll"),
170        )
171    }
172}
173
174/// UpperCamelCase identifier form of an arbitrary name. Word boundaries are
175/// runs of non-alphanumerics; existing camel humps are preserved.
176/// `"my-kv.store"` becomes `"MyKvStore"`.
177#[must_use]
178pub fn pascal_ident(name: &str) -> String {
179    let mut out = String::with_capacity(name.len());
180    let mut start_word = true;
181    for ch in name.chars() {
182        if ch.is_ascii_alphanumeric() {
183            if start_word {
184                out.push(ch.to_ascii_uppercase());
185            } else {
186                out.push(ch);
187            }
188            start_word = false;
189        } else {
190            start_word = true;
191        }
192    }
193    if out.starts_with(|c: char| c.is_ascii_digit()) {
194        out.insert(0, 'N');
195    }
196    if out.is_empty() {
197        pascal_ident(DEFAULT_NAME)
198    } else {
199        out
200    }
201}
202
203/// Lower-snake C identifier form of an arbitrary name: non-alphanumerics
204/// collapse to `_`, and a leading digit gets a `lib_` prefix.
205/// `"my-kv.store"` becomes `"my_kv_store"`.
206#[must_use]
207pub fn c_ident(name: &str) -> String {
208    let mut out = String::with_capacity(name.len());
209    let mut prev_us = false;
210    for ch in name.chars() {
211        if ch.is_ascii_alphanumeric() {
212            out.push(ch.to_ascii_lowercase());
213            prev_us = false;
214        } else if !prev_us && !out.is_empty() {
215            out.push('_');
216            prev_us = true;
217        }
218    }
219    let trimmed = out.trim_end_matches('_');
220    if trimmed.is_empty() {
221        DEFAULT_NAME.to_string()
222    } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
223        format!("lib_{trimmed}")
224    } else {
225        trimmed.to_string()
226    }
227}
228
229/// The file stem of a path-like input name: `"path/to/kv.store.yml"` becomes
230/// `"kv"`.
231#[must_use]
232pub fn name_from_basename(basename: &str) -> String {
233    let file = basename.rsplit(['/', '\\']).next().unwrap_or(basename);
234    file.split('.').next().unwrap_or(file).to_string()
235}
236
237fn non_empty(s: Option<&String>) -> Option<String> {
238    s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn name_drives_prefix_and_library() {
247        let id = Identity::named("my-kv.store");
248        assert_eq!(id.name, "my-kv.store");
249        assert_eq!(id.prefix, "my_kv_store");
250        assert_eq!(id.library, "my_kv_store");
251        assert_eq!(id.macro_prefix(), "MY_KV_STORE");
252        assert_eq!(id.pascal_name(), "MyKvStore");
253        assert_eq!(id.library_env_var(), "MY_KV_STORE_LIBRARY");
254        assert_eq!(id.version, DEFAULT_VERSION);
255    }
256
257    #[test]
258    fn package_table_wins() {
259        let pkg = Package {
260            name: Some("kvstore".into()),
261            version: Some("1.2.0".into()),
262            c_prefix: Some("kv".into()),
263            library: Some("kvstore_core".into()),
264            ..Package::default()
265        };
266        let id = Identity::new("ignored", &pkg);
267        assert_eq!(id.name, "kvstore");
268        assert_eq!(id.prefix, "kv");
269        assert_eq!(id.library, "kvstore_core");
270        assert_eq!(id.version, "1.2.0");
271        assert_eq!(id.description_or_default(), "Native bindings for kvstore");
272    }
273
274    #[test]
275    fn identifiers_sanitize() {
276        assert_eq!(c_ident("Kvstore"), "kvstore");
277        assert_eq!(c_ident("--"), DEFAULT_NAME);
278        assert_eq!(c_ident("3d-engine"), "lib_3d_engine");
279        assert_eq!(pascal_ident("kvstore"), "Kvstore");
280        assert_eq!(pascal_ident("3d"), "N3d");
281        assert_eq!(name_from_basename("path/to/contacts.yml"), "contacts");
282    }
283
284    #[test]
285    fn package_table_parses_from_toml() {
286        let pkg: Package =
287            toml::from_str("name = \"kv\"\nversion = \"2.0.0\"\nauthors = [\"A\", \"B\"]\n")
288                .unwrap();
289        assert_eq!(pkg.name.as_deref(), Some("kv"));
290        assert_eq!(pkg.authors.len(), 2);
291        assert!(toml::from_str::<Package>("bogus = 1").is_err());
292    }
293}