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        // Magic comment: frozen_string_literal (required for Ruby 3+)
99        header.push_str("# frozen_string_literal: true\n");
100
101        if metadata.auto_generated {
102            header.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
103            if let Some(schema) = &metadata.schema_file {
104                header.push_str(&format!("# Schema: {schema}\n"));
105            }
106            if let Some(version) = &metadata.generator_version {
107                header.push_str(&format!("# Generator: Spikard {version}\n"));
108            }
109        }
110
111        header
112    }
113
114    fn format_imports(&self, imports: &[Import]) -> String {
115        if imports.is_empty() {
116            return String::new();
117        }
118
119        // Separate stdlib and gem requires
120        let mut stdlib_requires = BTreeMap::new();
121        let mut gem_requires = BTreeMap::new();
122
123        for import in imports {
124            if Self::is_stdlib(&import.module) {
125                stdlib_requires.insert(import.module.clone(), import.items.clone());
126            } else {
127                gem_requires.insert(import.module.clone(), import.items.clone());
128            }
129        }
130
131        let mut result = String::new();
132
133        // Write stdlib requires first
134        for module in stdlib_requires.keys() {
135            result.push_str(&format!("require '{module}'\n"));
136        }
137
138        // Add blank line between stdlib and gems if both exist
139        if !stdlib_requires.is_empty() && !gem_requires.is_empty() {
140            result.push('\n');
141        }
142
143        // Write gem requires
144        for module in gem_requires.keys() {
145            result.push_str(&format!("require '{module}'\n"));
146        }
147
148        // Remove trailing newline (will be added during merge)
149        if result.ends_with('\n') {
150            result.pop();
151        }
152
153        result
154    }
155
156    fn format_docstring(&self, content: &str) -> String {
157        let lines: Vec<&str> = content.lines().collect();
158
159        if lines.is_empty() {
160            return String::new();
161        }
162
163        let mut result = String::new();
164
165        if lines.len() == 1 {
166            // Single-line YARD comment
167            result.push_str(&format!("# {}", lines[0]));
168        } else {
169            // Multi-line YARD documentation
170            for line in lines {
171                if line.trim().is_empty() {
172                    result.push_str("#\n");
173                } else {
174                    result.push_str(&format!("# {line}\n"));
175                }
176            }
177            // Remove trailing newline added by last iteration
178            if result.ends_with('\n') {
179                result.pop();
180            }
181        }
182
183        result
184    }
185
186    fn merge_sections(&self, sections: &[Section]) -> String {
187        let mut parts = Vec::new();
188
189        // Extract and organize sections
190        let mut header = String::new();
191        let mut imports = String::new();
192        let mut body = String::new();
193
194        for section in sections {
195            match section {
196                Section::Header(h) => header = h.clone(),
197                Section::Imports(i) => imports = i.clone(),
198                Section::Body(b) => body = b.clone(),
199            }
200        }
201
202        // Build final output
203        if !header.is_empty() {
204            parts.push(header);
205        }
206
207        if !imports.is_empty() {
208            parts.push(imports);
209        }
210
211        if !body.is_empty() {
212            parts.push(body);
213        }
214
215        // Join with single blank line between sections
216        let result = parts.join("\n\n");
217
218        // Ensure trailing newline
219        if result.is_empty() {
220            String::new()
221        } else if result.ends_with('\n') {
222            result
223        } else {
224            format!("{result}\n")
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_format_header_with_metadata() {
235        let formatter = RubyFormatter::new();
236        let metadata = HeaderMetadata {
237            auto_generated: true,
238            schema_file: Some("api.openapi.json".to_string()),
239            generator_version: Some("0.6.2".to_string()),
240        };
241        let header = formatter.format_header(&metadata);
242        assert!(header.starts_with("# frozen_string_literal: true"));
243        assert!(header.contains("DO NOT EDIT"));
244        assert!(header.contains("api.openapi.json"));
245        assert!(header.contains("0.6.2"));
246    }
247
248    #[test]
249    fn test_format_header_minimal() {
250        let formatter = RubyFormatter::new();
251        let metadata = HeaderMetadata {
252            auto_generated: false,
253            schema_file: None,
254            generator_version: None,
255        };
256        let header = formatter.format_header(&metadata);
257        assert!(header.starts_with("# frozen_string_literal: true"));
258    }
259
260    #[test]
261    fn test_format_imports_stdlib_first() {
262        let formatter = RubyFormatter::new();
263        let imports = vec![
264            Import {
265                module: "json".to_string(),
266                items: vec![],
267                is_type_only: false,
268            },
269            Import {
270                module: "sinatra".to_string(),
271                items: vec![],
272                is_type_only: false,
273            },
274        ];
275
276        let result = formatter.format_imports(&imports);
277        let json_pos = result.find("'json'").expect("json require");
278        let sinatra_pos = result.find("'sinatra'").expect("sinatra require");
279        assert!(json_pos < sinatra_pos, "stdlib should come before gems");
280        assert!(result.contains("\n\n"), "Should have blank line between groups");
281    }
282
283    #[test]
284    fn test_format_imports_sorted() {
285        let formatter = RubyFormatter::new();
286        let imports = vec![
287            Import {
288                module: "yaml".to_string(),
289                items: vec![],
290                is_type_only: false,
291            },
292            Import {
293                module: "json".to_string(),
294                items: vec![],
295                is_type_only: false,
296            },
297        ];
298
299        let result = formatter.format_imports(&imports);
300        let json_pos = result.find("'json'").expect("json require");
301        let yaml_pos = result.find("'yaml'").expect("yaml require");
302        assert!(json_pos < yaml_pos, "Should be sorted alphabetically");
303    }
304
305    #[test]
306    fn test_is_stdlib() {
307        assert!(RubyFormatter::is_stdlib("json"));
308        assert!(RubyFormatter::is_stdlib("yaml"));
309        assert!(RubyFormatter::is_stdlib("net/http"));
310        assert!(!RubyFormatter::is_stdlib("rails"));
311        assert!(!RubyFormatter::is_stdlib("sinatra"));
312    }
313
314    #[test]
315    fn test_format_docstring_single_line() {
316        let formatter = RubyFormatter::new();
317        let doc = formatter.format_docstring("User API handler");
318        assert_eq!(doc, "# User API handler");
319    }
320
321    #[test]
322    fn test_format_docstring_multiline() {
323        let formatter = RubyFormatter::new();
324        let content = "User API handler\nHandles user CRUD\nOperations";
325        let doc = formatter.format_docstring(content);
326        assert!(doc.starts_with("# User API handler"));
327        assert!(doc.contains("# Handles user CRUD"));
328        assert!(doc.contains("# Operations"));
329    }
330
331    #[test]
332    fn test_format_docstring_preserves_empty_lines() {
333        let formatter = RubyFormatter::new();
334        let content = "User API\n\nDetailed description";
335        let doc = formatter.format_docstring(content);
336        assert!(doc.contains("#\n"));
337    }
338
339    #[test]
340    fn test_merge_sections() {
341        let formatter = RubyFormatter::new();
342        let sections = vec![
343            Section::Header("# frozen_string_literal: true".to_string()),
344            Section::Imports("require 'json'".to_string()),
345            Section::Body("class User\nend".to_string()),
346        ];
347
348        let result = formatter.merge_sections(&sections);
349        assert!(result.contains("frozen_string_literal"));
350        assert!(result.contains("require"));
351        assert!(result.contains("class"));
352        assert!(result.ends_with('\n'));
353    }
354
355    #[test]
356    fn test_merge_sections_blank_line_between() {
357        let formatter = RubyFormatter::new();
358        let sections = vec![
359            Section::Header("# Header".to_string()),
360            Section::Imports("require 'json'".to_string()),
361        ];
362
363        let result = formatter.merge_sections(&sections);
364        assert!(result.contains("\n\n"));
365    }
366}