Skip to main content

normalize_languages/
dockerfile.rs

1//! Dockerfile language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// Dockerfile language support.
9pub struct Dockerfile;
10
11impl Language for Dockerfile {
12    fn name(&self) -> &'static str {
13        "Dockerfile"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["dockerfile"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "dockerfile"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    // Dockerfiles have stages (FROM ... AS name) that act as containers
27    fn container_kinds(&self) -> &'static [&'static str] {
28        &["from_instruction"]
29    }
30
31    // No functions in Dockerfile
32    fn function_kinds(&self) -> &'static [&'static str] {
33        &[]
34    }
35
36    fn type_kinds(&self) -> &'static [&'static str] {
37        &[]
38    }
39
40    fn import_kinds(&self) -> &'static [&'static str] {
41        &["from_instruction"]
42    }
43
44    fn public_symbol_kinds(&self) -> &'static [&'static str] {
45        &["from_instruction"]
46    }
47
48    fn visibility_mechanism(&self) -> VisibilityMechanism {
49        VisibilityMechanism::NotApplicable
50    }
51
52    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
53        if node.kind() != "from_instruction" {
54            return Vec::new();
55        }
56
57        // Extract the stage name (FROM image AS name)
58        if let Some(name) = self.extract_stage_name(node, content) {
59            return vec![Export {
60                name,
61                kind: SymbolKind::Module,
62                line: node.start_position().row + 1,
63            }];
64        }
65
66        Vec::new()
67    }
68
69    fn scope_creating_kinds(&self) -> &'static [&'static str] {
70        &[]
71    }
72    fn control_flow_kinds(&self) -> &'static [&'static str] {
73        &[]
74    }
75    fn complexity_nodes(&self) -> &'static [&'static str] {
76        &[]
77    }
78    fn nesting_nodes(&self) -> &'static [&'static str] {
79        &[]
80    }
81
82    fn signature_suffix(&self) -> &'static str {
83        ""
84    }
85
86    fn extract_function(
87        &self,
88        _node: &Node,
89        _content: &str,
90        _in_container: bool,
91    ) -> Option<Symbol> {
92        None
93    }
94
95    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
96        if node.kind() != "from_instruction" {
97            return None;
98        }
99
100        // Extract base image
101        let image_name = self.extract_image_name(node, content)?;
102        let stage_name = self.extract_stage_name(node, content);
103
104        let name = stage_name.clone().unwrap_or_else(|| image_name.clone());
105        let signature = if let Some(stage) = stage_name {
106            format!("FROM {} AS {}", image_name, stage)
107        } else {
108            format!("FROM {}", image_name)
109        };
110
111        Some(Symbol {
112            name,
113            kind: SymbolKind::Module,
114            signature,
115            docstring: None,
116            attributes: Vec::new(),
117            start_line: node.start_position().row + 1,
118            end_line: node.end_position().row + 1,
119            visibility: Visibility::Public,
120            children: Vec::new(),
121            is_interface_impl: false,
122            implements: Vec::new(),
123        })
124    }
125
126    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
127        None
128    }
129    fn extract_docstring(&self, _node: &Node, _content: &str) -> Option<String> {
130        None
131    }
132
133    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
134        Vec::new()
135    }
136
137    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
138        if node.kind() != "from_instruction" {
139            return Vec::new();
140        }
141
142        if let Some(image) = self.extract_image_name(node, content) {
143            return vec![Import {
144                module: image,
145                names: Vec::new(),
146                alias: self.extract_stage_name(node, content),
147                is_wildcard: false,
148                is_relative: false,
149                line: node.start_position().row + 1,
150            }];
151        }
152
153        Vec::new()
154    }
155
156    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
157        // Dockerfile: FROM image
158        format!("FROM {}", import.module)
159    }
160
161    fn is_public(&self, _node: &Node, _content: &str) -> bool {
162        true
163    }
164    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
165        Visibility::Public
166    }
167
168    fn is_test_symbol(&self, _symbol: &crate::Symbol) -> bool {
169        false
170    }
171
172    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
173        None
174    }
175
176    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
177        None
178    }
179    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
180        false
181    }
182    fn node_name<'a>(&self, _node: &Node, _content: &'a str) -> Option<&'a str> {
183        None
184    }
185
186    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
187        let name = path.file_name()?.to_str()?;
188        if name.to_lowercase() == "dockerfile" || name.ends_with(".dockerfile") {
189            Some(name.to_string())
190        } else {
191            None
192        }
193    }
194
195    fn module_name_to_paths(&self, _module: &str) -> Vec<String> {
196        vec!["Dockerfile".to_string()]
197    }
198
199    fn lang_key(&self) -> &'static str {
200        "dockerfile"
201    }
202
203    fn is_stdlib_import(&self, _import_name: &str, _project_root: &Path) -> bool {
204        false
205    }
206    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
207        None
208    }
209
210    fn resolve_local_import(
211        &self,
212        _import: &str,
213        _current_file: &Path,
214        _project_root: &Path,
215    ) -> Option<PathBuf> {
216        None
217    }
218
219    fn resolve_external_import(
220        &self,
221        _import_name: &str,
222        _project_root: &Path,
223    ) -> Option<ResolvedPackage> {
224        // Could resolve Docker Hub images here
225        None
226    }
227
228    fn get_version(&self, _project_root: &Path) -> Option<String> {
229        None
230    }
231    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
232        None
233    }
234    fn indexable_extensions(&self) -> &'static [&'static str] {
235        &[]
236    }
237    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
238        Vec::new()
239    }
240
241    fn should_skip_package_entry(&self, name: &str, _is_dir: bool) -> bool {
242        use crate::traits::skip_dotfiles;
243        skip_dotfiles(name)
244    }
245
246    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
247        Vec::new()
248    }
249
250    fn package_module_name(&self, entry_name: &str) -> String {
251        entry_name.to_string()
252    }
253
254    fn find_package_entry(&self, _path: &Path) -> Option<PathBuf> {
255        None
256    }
257}
258
259impl Dockerfile {
260    /// Extract the image name from a FROM instruction
261    fn extract_image_name(&self, node: &Node, content: &str) -> Option<String> {
262        let mut cursor = node.walk();
263        for child in node.children(&mut cursor) {
264            if child.kind() == "image_spec" {
265                return Some(content[child.byte_range()].to_string());
266            }
267        }
268        None
269    }
270
271    /// Extract the stage name from a FROM instruction (FROM image AS name)
272    fn extract_stage_name(&self, node: &Node, content: &str) -> Option<String> {
273        let mut cursor = node.walk();
274        let mut found_as = false;
275        for child in node.children(&mut cursor) {
276            if found_as && child.kind() == "image_alias" {
277                return Some(content[child.byte_range()].to_string());
278            }
279            if child.kind() == "as_instruction" {
280                found_as = true;
281            }
282        }
283        None
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::validate_unused_kinds_audit;
291
292    #[test]
293    fn unused_node_kinds_audit() {
294        #[rustfmt::skip]
295        let documented_unused: &[&str] = &[
296            // All Dockerfile instruction types (we don't track these as symbols)
297            "add_instruction", "arg_instruction", "cmd_instruction", "copy_instruction",
298            "cross_build_instruction", "entrypoint_instruction", "env_instruction",
299            "expose_instruction", "healthcheck_instruction", "heredoc_block",
300            "label_instruction", "maintainer_instruction", "onbuild_instruction",
301            "run_instruction", "shell_instruction", "stopsignal_instruction",
302            "user_instruction", "volume_instruction", "workdir_instruction",
303        ];
304
305        validate_unused_kinds_audit(&Dockerfile, documented_unused)
306            .expect("Dockerfile unused node kinds audit failed");
307    }
308}