spikard_cli/codegen/formatters/
ruby.rs1use super::{Formatter, HeaderMetadata, Import, Section};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone)]
42pub struct RubyFormatter;
43
44impl RubyFormatter {
45 #[must_use]
47 pub const fn new() -> Self {
48 Self
49 }
50
51 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(§ions);
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(§ions);
351 assert!(result.contains("\n\n"));
352 }
353}