Skip to main content

spikard_cli/codegen/formatters/
ruby.rs

1//! Ruby code formatter.
2//!
3//! Implements the `Formatter` trait for Ruby code generation, ensuring output
4//! adheres to Ruby 3.2+ standards, Rubocop conventions, and follows spikard's
5//! type safety patterns.
6//!
7//! # Features
8//!
9//! - **Headers**: `frozen_string_literal` magic comment, auto-generation notices
10//! - **Imports**: Grouped and sorted (stdlib, then gems, alphabetically)
11//! - **Docstrings**: YARD format with proper indentation
12//! - **Spacing**: Single blank line between class/module definitions
13
14use super::{Formatter, HeaderMetadata, Import, Section};
15use std::collections::BTreeMap;
16
17/// Ruby code formatter implementing language-specific conventions
18///
19/// Formats generated Ruby code to comply with:
20/// - Ruby 3.2+ syntax requirements
21/// - Rubocop linting rules
22/// - YARD documentation standards
23/// - RBS type annotation compatibility
24///
25/// # Example
26///
27/// ```
28/// use spikard_cli::codegen::formatters::{Formatter, RubyFormatter, HeaderMetadata, Import};
29///
30/// let formatter = RubyFormatter::new();
31/// let metadata = HeaderMetadata {
32///     auto_generated: true,
33///     schema_file: Some("api.openapi.json".to_string()),
34///     generator_version: Some("0.6.2".to_string()),
35/// };
36///
37/// let header = formatter.format_header(&metadata);
38/// assert!(header.contains("frozen_string_literal"));
39/// assert!(header.contains("DO NOT EDIT"));
40/// ```
41#[derive(Debug, Clone)]
42pub struct RubyFormatter;
43
44impl RubyFormatter {
45    /// Create a new Ruby code formatter
46    #[must_use]
47    pub const fn new() -> Self {
48        Self
49    }
50
51    /// Determine if a require is from the Ruby standard library.
52    fn is_stdlib(name: &str) -> bool {
53        matches!(
54            name,
55            "json"
56                | "yaml"
57                | "time"
58                | "date"
59                | "set"
60                | "digest"
61                | "fileutils"
62                | "pathname"
63                | "net/http"
64                | "uri"
65                | "stringio"
66                | "tmpdir"
67                | "tempfile"
68                | "thread"
69                | "socket"
70                | "openssl"
71                | "csv"
72                | "logger"
73                | "singleton"
74                | "forwardable"
75                | "delegate"
76                | "optparse"
77                | "getoptlong"
78                | "timeout"
79                | "securerandom"
80                | "base64"
81                | "rexml"
82                | "webrick"
83                | "erb"
84        )
85    }
86}
87
88impl Default for RubyFormatter {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Formatter for RubyFormatter {
95    fn format_header(&self, metadata: &HeaderMetadata) -> String {
96        let mut header = String::new();
97
98        header.push_str("# frozen_string_literal: true\n");
99
100        if metadata.auto_generated {
101            header.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
102            if let Some(schema) = &metadata.schema_file {
103                header.push_str(&format!("# Schema: {schema}\n"));
104            }
105            if let Some(version) = &metadata.generator_version {
106                header.push_str(&format!("# Generator: Spikard {version}\n"));
107            }
108        }
109
110        header
111    }
112
113    fn format_imports(&self, imports: &[Import]) -> String {
114        if imports.is_empty() {
115            return String::new();
116        }
117
118        let mut stdlib_requires = BTreeMap::new();
119        let mut gem_requires = BTreeMap::new();
120
121        for import in imports {
122            if Self::is_stdlib(&import.module) {
123                stdlib_requires.insert(import.module.clone(), import.items.clone());
124            } else {
125                gem_requires.insert(import.module.clone(), import.items.clone());
126            }
127        }
128
129        let mut result = String::new();
130
131        for module in stdlib_requires.keys() {
132            result.push_str(&format!("require '{module}'\n"));
133        }
134
135        if !stdlib_requires.is_empty() && !gem_requires.is_empty() {
136            result.push('\n');
137        }
138
139        for module in gem_requires.keys() {
140            result.push_str(&format!("require '{module}'\n"));
141        }
142
143        if result.ends_with('\n') {
144            result.pop();
145        }
146
147        result
148    }
149
150    fn format_docstring(&self, content: &str) -> String {
151        let lines: Vec<&str> = content.lines().collect();
152
153        if lines.is_empty() {
154            return String::new();
155        }
156
157        let mut result = String::new();
158
159        if lines.len() == 1 {
160            result.push_str(&format!("# {}", lines[0]));
161        } else {
162            for line in lines {
163                if line.trim().is_empty() {
164                    result.push_str("#\n");
165                } else {
166                    result.push_str(&format!("# {line}\n"));
167                }
168            }
169            if result.ends_with('\n') {
170                result.pop();
171            }
172        }
173
174        result
175    }
176
177    fn merge_sections(&self, sections: &[Section]) -> String {
178        let mut parts = Vec::new();
179
180        let mut header = String::new();
181        let mut imports = String::new();
182        let mut body = String::new();
183
184        for section in sections {
185            match section {
186                Section::Header(h) => header = h.clone(),
187                Section::Imports(i) => imports = i.clone(),
188                Section::Body(b) => body = b.clone(),
189            }
190        }
191
192        if !header.is_empty() {
193            parts.push(header);
194        }
195
196        if !imports.is_empty() {
197            parts.push(imports);
198        }
199
200        if !body.is_empty() {
201            parts.push(body);
202        }
203
204        let result = parts.join("\n\n");
205
206        if result.is_empty() {
207            String::new()
208        } else if result.ends_with('\n') {
209            result
210        } else {
211            format!("{result}\n")
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_format_header_with_metadata() {
222        let formatter = RubyFormatter::new();
223        let metadata = HeaderMetadata {
224            auto_generated: true,
225            schema_file: Some("api.openapi.json".to_string()),
226            generator_version: Some("0.6.2".to_string()),
227        };
228        let header = formatter.format_header(&metadata);
229        assert!(header.starts_with("# frozen_string_literal: true"));
230        assert!(header.contains("DO NOT EDIT"));
231        assert!(header.contains("api.openapi.json"));
232        assert!(header.contains("0.6.2"));
233    }
234
235    #[test]
236    fn test_format_header_minimal() {
237        let formatter = RubyFormatter::new();
238        let metadata = HeaderMetadata {
239            auto_generated: false,
240            schema_file: None,
241            generator_version: None,
242        };
243        let header = formatter.format_header(&metadata);
244        assert!(header.starts_with("# frozen_string_literal: true"));
245    }
246
247    #[test]
248    fn test_format_imports_stdlib_first() {
249        let formatter = RubyFormatter::new();
250        let imports = vec![
251            Import {
252                module: "json".to_string(),
253                items: vec![],
254                is_type_only: false,
255            },
256            Import {
257                module: "sinatra".to_string(),
258                items: vec![],
259                is_type_only: false,
260            },
261        ];
262
263        let result = formatter.format_imports(&imports);
264        let json_pos = result.find("'json'").expect("json require");
265        let sinatra_pos = result.find("'sinatra'").expect("sinatra require");
266        assert!(json_pos < sinatra_pos, "stdlib should come before gems");
267        assert!(result.contains("\n\n"), "Should have blank line between groups");
268    }
269
270    #[test]
271    fn test_format_imports_sorted() {
272        let formatter = RubyFormatter::new();
273        let imports = vec![
274            Import {
275                module: "yaml".to_string(),
276                items: vec![],
277                is_type_only: false,
278            },
279            Import {
280                module: "json".to_string(),
281                items: vec![],
282                is_type_only: false,
283            },
284        ];
285
286        let result = formatter.format_imports(&imports);
287        let json_pos = result.find("'json'").expect("json require");
288        let yaml_pos = result.find("'yaml'").expect("yaml require");
289        assert!(json_pos < yaml_pos, "Should be sorted alphabetically");
290    }
291
292    #[test]
293    fn test_is_stdlib() {
294        assert!(RubyFormatter::is_stdlib("json"));
295        assert!(RubyFormatter::is_stdlib("yaml"));
296        assert!(RubyFormatter::is_stdlib("net/http"));
297        assert!(!RubyFormatter::is_stdlib("rails"));
298        assert!(!RubyFormatter::is_stdlib("sinatra"));
299    }
300
301    #[test]
302    fn test_format_docstring_single_line() {
303        let formatter = RubyFormatter::new();
304        let doc = formatter.format_docstring("User API handler");
305        assert_eq!(doc, "# User API handler");
306    }
307
308    #[test]
309    fn test_format_docstring_multiline() {
310        let formatter = RubyFormatter::new();
311        let content = "User API handler\nHandles user CRUD\nOperations";
312        let doc = formatter.format_docstring(content);
313        assert!(doc.starts_with("# User API handler"));
314        assert!(doc.contains("# Handles user CRUD"));
315        assert!(doc.contains("# Operations"));
316    }
317
318    #[test]
319    fn test_format_docstring_preserves_empty_lines() {
320        let formatter = RubyFormatter::new();
321        let content = "User API\n\nDetailed description";
322        let doc = formatter.format_docstring(content);
323        assert!(doc.contains("#\n"));
324    }
325
326    #[test]
327    fn test_merge_sections() {
328        let formatter = RubyFormatter::new();
329        let sections = vec![
330            Section::Header("# frozen_string_literal: true".to_string()),
331            Section::Imports("require 'json'".to_string()),
332            Section::Body("class User\nend".to_string()),
333        ];
334
335        let result = formatter.merge_sections(&sections);
336        assert!(result.contains("frozen_string_literal"));
337        assert!(result.contains("require"));
338        assert!(result.contains("class"));
339        assert!(result.ends_with('\n'));
340    }
341
342    #[test]
343    fn test_merge_sections_blank_line_between() {
344        let formatter = RubyFormatter::new();
345        let sections = vec![
346            Section::Header("# Header".to_string()),
347            Section::Imports("require 'json'".to_string()),
348        ];
349
350        let result = formatter.merge_sections(&sections);
351        assert!(result.contains("\n\n"));
352    }
353}