amalgam_parser/
imports.rs

1//! Import resolution for cross-package type references
2
3use amalgam_core::types::Type;
4use std::collections::{HashMap, HashSet};
5
6/// Represents a type reference that needs to be imported
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct TypeReference {
9    /// Group (e.g., "k8s.io", "apiextensions.crossplane.io")
10    pub group: String,
11    /// Version (e.g., "v1", "v1beta1")
12    pub version: String,
13    /// Kind (e.g., "ObjectMeta", "Volume")
14    pub kind: String,
15}
16
17impl TypeReference {
18    pub fn new(group: String, version: String, kind: String) -> Self {
19        Self {
20            group,
21            version,
22            kind,
23        }
24    }
25
26    /// Parse a fully qualified type reference like "io.k8s.api.core.v1.ObjectMeta"
27    pub fn from_qualified_name(name: &str) -> Option<Self> {
28        // Handle various formats:
29        // - io.k8s.api.core.v1.ObjectMeta
30        // - k8s.io/api/core/v1.ObjectMeta
31        // - v1.ObjectMeta (assume k8s.io/api/core)
32
33        if name.starts_with("io.k8s.") {
34            // Handle various k8s formats:
35            // - io.k8s.api.core.v1.Pod
36            // - io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta
37            let parts: Vec<&str> = name.split('.').collect();
38
39            if name.starts_with("io.k8s.apimachinery.pkg.apis.meta.") && parts.len() >= 8 {
40                // Special case for apimachinery types
41                let version = parts[parts.len() - 2].to_string();
42                let kind = parts[parts.len() - 1].to_string();
43                return Some(Self::new("k8s.io".to_string(), version, kind));
44            } else if name.starts_with("io.k8s.api.") && parts.len() >= 5 {
45                // Standard API types
46                let group = if parts[3] == "core" {
47                    "k8s.io".to_string()
48                } else {
49                    format!("{}.k8s.io", parts[3])
50                };
51                let version = parts[parts.len() - 2].to_string();
52                let kind = parts[parts.len() - 1].to_string();
53                return Some(Self::new(group, version, kind));
54            }
55        } else if name.contains('/') {
56            // Format: k8s.io/api/core/v1.ObjectMeta
57            let parts: Vec<&str> = name.split('/').collect();
58            if let Some(last) = parts.last() {
59                let type_parts: Vec<&str> = last.split('.').collect();
60                if type_parts.len() == 2 {
61                    let version = type_parts[0].to_string();
62                    let kind = type_parts[1].to_string();
63                    let group = parts[0].to_string();
64                    return Some(Self::new(group, version, kind));
65                }
66            }
67        } else if name.starts_with("v1.")
68            || name.starts_with("v1beta1.")
69            || name.starts_with("v1alpha1.")
70        {
71            // Short format: v1.ObjectMeta (assume core k8s types)
72            let parts: Vec<&str> = name.split('.').collect();
73            if parts.len() == 2 {
74                return Some(Self::new(
75                    "k8s.io".to_string(),
76                    parts[0].to_string(),
77                    parts[1].to_string(),
78                ));
79            }
80        }
81
82        None
83    }
84
85    /// Get the import path for this reference relative to a base path
86    pub fn import_path(&self, from_group: &str, from_version: &str) -> String {
87        // Generic approach: Calculate the relative path between any two files
88        // Package layout convention:
89        //   vendor_dir/
90        //     ├── package_dir/        <- derived from group name
91        //     │   └── [group_path]/version/file.ncl
92        //     └── other_package/
93        //         └── [group_path]/version/file.ncl
94
95        // Helper to derive package directory from group name
96        let group_to_package = |group: &str| -> String {
97            // Convention:
98            // - Replace dots with underscores for filesystem compatibility
99            // - If the result would be just an org name (e.g., "crossplane_io"),
100            //   try to extract a more meaningful package name
101            let sanitized = group.replace('.', "_");
102
103            // If it ends with a common TLD pattern, extract the org name
104            if group.contains('.') {
105                // For domains like "apiextensions.crossplane.io", we want "crossplane"
106                // For domains like "k8s.io", we want "k8s_io"
107                let parts: Vec<&str> = group.split('.').collect();
108                if parts.len() >= 2
109                    && (parts.last() == Some(&"io")
110                        || parts.last() == Some(&"com")
111                        || parts.last() == Some(&"org"))
112                {
113                    // If there's a clear org name, use it
114                    if parts.len() == 2 {
115                        // Simple case like "k8s.io" -> "k8s_io"
116                        sanitized
117                    } else if parts.len() >= 3 {
118                        // Complex case like "apiextensions.crossplane.io"
119                        // Take the second-to-last part as the org name
120                        parts[parts.len() - 2].to_string()
121                    } else {
122                        sanitized
123                    }
124                } else {
125                    sanitized
126                }
127            } else {
128                sanitized
129            }
130        };
131
132        // Helper to determine if a group needs its own subdirectory within the package
133        let needs_group_subdir = |group: &str, package: &str| -> bool {
134            // If the package name is derived from only part of the group,
135            // we need a subdirectory for the full group
136            let sanitized = group.replace('.', "_");
137            sanitized != package && group.contains('.')
138        };
139
140        // Build the from path components
141        let from_package = group_to_package(from_group);
142        let mut from_components: Vec<String> = Vec::new();
143        from_components.push(from_package.clone());
144
145        if needs_group_subdir(from_group, &from_package) {
146            from_components.push(from_group.to_string());
147        }
148        from_components.push(from_version.to_string());
149
150        // Build the target path components
151        let target_package = group_to_package(&self.group);
152        let mut to_components: Vec<String> = Vec::new();
153        to_components.push(target_package.clone());
154
155        if needs_group_subdir(&self.group, &target_package) {
156            to_components.push(self.group.clone());
157        }
158        to_components.push(self.version.clone());
159        to_components.push(format!("{}.ncl", self.kind.to_lowercase()));
160
161        // Calculate the relative path
162        // From a file at: vendor/package1/group/version/file.ncl
163        // We need to go up to vendor/ then down to package2/...
164        // The number of ../ equals the depth from the file to the vendor directory
165        // which is the number of path components minus the vendor itself
166        let up_count = from_components.len();
167        let up_dirs = "../".repeat(up_count);
168        let down_path = to_components.join("/");
169
170        format!("{}{}", up_dirs, down_path)
171    }
172
173    /// Get the module alias for imports
174    pub fn module_alias(&self) -> String {
175        format!(
176            "{}_{}",
177            self.group.replace(['.', '-'], "_"),
178            self.version.replace('-', "_")
179        )
180    }
181}
182
183/// Analyzes types to find external references that need imports
184pub struct ImportResolver {
185    /// Set of all type references found
186    references: HashSet<TypeReference>,
187    /// Known types that are already defined locally
188    local_types: HashSet<String>,
189}
190
191impl Default for ImportResolver {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl ImportResolver {
198    pub fn new() -> Self {
199        Self {
200            references: HashSet::new(),
201            local_types: HashSet::new(),
202        }
203    }
204
205    /// Add a locally defined type
206    pub fn add_local_type(&mut self, name: &str) {
207        self.local_types.insert(name.to_string());
208    }
209
210    /// Analyze a type and collect external references
211    pub fn analyze_type(&mut self, ty: &Type) {
212        match ty {
213            Type::Reference(name) => {
214                // Check if this is an external reference
215                if !self.local_types.contains(name) {
216                    if let Some(type_ref) = TypeReference::from_qualified_name(name) {
217                        tracing::trace!("ImportResolver: found external reference: {:?}", type_ref);
218                        self.references.insert(type_ref);
219                    } else {
220                        tracing::trace!("ImportResolver: could not parse reference: {}", name);
221                    }
222                }
223            }
224            Type::Array(inner) => self.analyze_type(inner),
225            Type::Optional(inner) => self.analyze_type(inner),
226            Type::Map { value, .. } => self.analyze_type(value),
227            Type::Record { fields, .. } => {
228                for field in fields.values() {
229                    self.analyze_type(&field.ty);
230                }
231            }
232            Type::Union(types) => {
233                for ty in types {
234                    self.analyze_type(ty);
235                }
236            }
237            Type::TaggedUnion { variants, .. } => {
238                for ty in variants.values() {
239                    self.analyze_type(ty);
240                }
241            }
242            Type::Contract { base, .. } => self.analyze_type(base),
243            _ => {}
244        }
245    }
246
247    /// Get all collected references
248    pub fn references(&self) -> &HashSet<TypeReference> {
249        &self.references
250    }
251
252    /// Generate import statements for Nickel
253    pub fn generate_imports(&self, from_group: &str, from_version: &str) -> Vec<String> {
254        let mut imports = Vec::new();
255
256        // Group references by their module
257        let mut by_module: HashMap<String, Vec<&TypeReference>> = HashMap::new();
258        for type_ref in &self.references {
259            let module_key = format!("{}/{}", type_ref.group, type_ref.version);
260            by_module.entry(module_key).or_default().push(type_ref);
261        }
262
263        // Generate import statements
264        for (_module, refs) in by_module {
265            let first_ref = refs[0];
266            let import_path = first_ref.import_path(from_group, from_version);
267            let alias = first_ref.module_alias();
268
269            imports.push(format!("let {} = import \"{}\" in", alias, import_path));
270        }
271
272        imports.sort();
273        imports
274    }
275}
276
277/// Common Kubernetes types that are frequently referenced
278pub fn common_k8s_types() -> Vec<TypeReference> {
279    vec![
280        TypeReference::new(
281            "k8s.io".to_string(),
282            "v1".to_string(),
283            "ObjectMeta".to_string(),
284        ),
285        TypeReference::new(
286            "k8s.io".to_string(),
287            "v1".to_string(),
288            "ListMeta".to_string(),
289        ),
290        TypeReference::new(
291            "k8s.io".to_string(),
292            "v1".to_string(),
293            "TypeMeta".to_string(),
294        ),
295        TypeReference::new(
296            "k8s.io".to_string(),
297            "v1".to_string(),
298            "LabelSelector".to_string(),
299        ),
300        TypeReference::new("k8s.io".to_string(), "v1".to_string(), "Volume".to_string()),
301        TypeReference::new(
302            "k8s.io".to_string(),
303            "v1".to_string(),
304            "VolumeMount".to_string(),
305        ),
306        TypeReference::new(
307            "k8s.io".to_string(),
308            "v1".to_string(),
309            "Container".to_string(),
310        ),
311        TypeReference::new(
312            "k8s.io".to_string(),
313            "v1".to_string(),
314            "PodSpec".to_string(),
315        ),
316        TypeReference::new(
317            "k8s.io".to_string(),
318            "v1".to_string(),
319            "ResourceRequirements".to_string(),
320        ),
321        TypeReference::new(
322            "k8s.io".to_string(),
323            "v1".to_string(),
324            "Affinity".to_string(),
325        ),
326        TypeReference::new(
327            "k8s.io".to_string(),
328            "v1".to_string(),
329            "Toleration".to_string(),
330        ),
331        TypeReference::new("k8s.io".to_string(), "v1".to_string(), "EnvVar".to_string()),
332        TypeReference::new(
333            "k8s.io".to_string(),
334            "v1".to_string(),
335            "ConfigMapKeySelector".to_string(),
336        ),
337        TypeReference::new(
338            "k8s.io".to_string(),
339            "v1".to_string(),
340            "SecretKeySelector".to_string(),
341        ),
342    ]
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_parse_qualified_name() {
351        let ref1 = TypeReference::from_qualified_name("io.k8s.api.core.v1.ObjectMeta");
352        assert!(ref1.is_some());
353        let ref1 = ref1.unwrap();
354        assert_eq!(ref1.group, "k8s.io");
355        assert_eq!(ref1.version, "v1");
356        assert_eq!(ref1.kind, "ObjectMeta");
357
358        let ref2 = TypeReference::from_qualified_name("v1.Volume");
359        assert!(ref2.is_some());
360        let ref2 = ref2.unwrap();
361        assert_eq!(ref2.group, "k8s.io");
362        assert_eq!(ref2.version, "v1");
363        assert_eq!(ref2.kind, "Volume");
364    }
365
366    #[test]
367    fn test_import_path() {
368        let type_ref = TypeReference::new(
369            "k8s.io".to_string(),
370            "v1".to_string(),
371            "ObjectMeta".to_string(),
372        );
373
374        // Test with a Crossplane group (2+ dots)
375        let path = type_ref.import_path("apiextensions.crossplane.io", "v1");
376        assert_eq!(path, "../../../k8s_io/v1/objectmeta.ncl");
377
378        // Test with a simple group (1 dot)
379        let path2 = type_ref.import_path("example.io", "v1");
380        assert_eq!(path2, "../../k8s_io/v1/objectmeta.ncl");
381    }
382}