rfmt 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ //! FormatContext - State management for formatting
2
+ //!
3
+ //! FormatContext encapsulates all state needed during formatting:
4
+ //! - Source code reference
5
+ //! - Configuration
6
+ //! - Comment tracking and emission
7
+ //! - Group ID generation for Doc IR
8
+
9
+ use crate::ast::{Comment, Node};
10
+ use crate::config::Config;
11
+ use std::collections::{BTreeMap, HashSet};
12
+
13
+ /// Formatting context that manages state during AST traversal.
14
+ ///
15
+ /// This struct is passed to FormatRules and provides access to:
16
+ /// - Source code for extraction
17
+ /// - Configuration settings
18
+ /// - Comment management (collection, emission tracking)
19
+ /// - Group ID generation for Doc IR
20
+ pub struct FormatContext<'a> {
21
+ /// Reference to the configuration
22
+ config: &'a Config,
23
+
24
+ /// Reference to the source code
25
+ source: &'a str,
26
+
27
+ /// Source lines cached for efficient access
28
+ source_lines: Vec<&'a str>,
29
+
30
+ /// All comments collected from the AST
31
+ all_comments: Vec<Comment>,
32
+
33
+ /// Indices of comments that have been emitted
34
+ emitted_comment_indices: HashSet<usize>,
35
+
36
+ /// Index of comment indices by start line for O(log n) lookup
37
+ /// Key: start_line, Value: Vec of comment indices that start on that line
38
+ comments_by_line: BTreeMap<usize, Vec<usize>>,
39
+
40
+ /// Counter for generating unique group IDs
41
+ next_group_id: u32,
42
+ }
43
+
44
+ impl<'a> FormatContext<'a> {
45
+ /// Creates a new FormatContext with the given configuration and source code.
46
+ pub fn new(config: &'a Config, source: &'a str) -> Self {
47
+ Self {
48
+ config,
49
+ source,
50
+ source_lines: source.lines().collect(),
51
+ all_comments: Vec::new(),
52
+ emitted_comment_indices: HashSet::new(),
53
+ comments_by_line: BTreeMap::new(),
54
+ next_group_id: 0,
55
+ }
56
+ }
57
+
58
+ /// Returns a reference to the configuration.
59
+ pub fn config(&self) -> &Config {
60
+ self.config
61
+ }
62
+
63
+ /// Returns a reference to the source code.
64
+ pub fn source(&self) -> &str {
65
+ self.source
66
+ }
67
+
68
+ /// Generates a new unique group ID for Doc IR.
69
+ pub fn next_group_id(&mut self) -> u32 {
70
+ let id = self.next_group_id;
71
+ self.next_group_id += 1;
72
+ id
73
+ }
74
+
75
+ /// Collects all comments from the AST recursively.
76
+ pub fn collect_comments(&mut self, root: &Node) {
77
+ self.all_comments.clear();
78
+ self.emitted_comment_indices.clear();
79
+ self.comments_by_line.clear();
80
+
81
+ // Use iterative approach with stack to avoid deep recursion
82
+ let mut stack = vec![root];
83
+ while let Some(node) = stack.pop() {
84
+ // Reserve capacity hint based on typical comment count
85
+ if self.all_comments.is_empty() && !node.comments.is_empty() {
86
+ self.all_comments.reserve(node.comments.len() * 4);
87
+ }
88
+ self.all_comments.extend(node.comments.iter().cloned());
89
+ // Process children in reverse order to maintain order when popping
90
+ stack.extend(node.children.iter().rev());
91
+ }
92
+
93
+ self.build_comment_index();
94
+ }
95
+
96
+ /// Builds the comment index by start line for O(log n) range lookups.
97
+ fn build_comment_index(&mut self) {
98
+ for (idx, comment) in self.all_comments.iter().enumerate() {
99
+ self.comments_by_line
100
+ .entry(comment.location.start_line)
101
+ .or_default()
102
+ .push(idx);
103
+ }
104
+ }
105
+
106
+ /// Gets comments that appear before a given line (not emitted yet).
107
+ ///
108
+ /// Returns comments where the entire comment ends before the given line.
109
+ pub fn get_comments_before(&self, line: usize) -> Vec<&Comment> {
110
+ self.comments_by_line
111
+ .range(..line)
112
+ .flat_map(|(_, indices)| indices.iter())
113
+ .filter(|&&idx| {
114
+ !self.emitted_comment_indices.contains(&idx)
115
+ && self.all_comments[idx].location.end_line < line
116
+ })
117
+ .map(|&idx| &self.all_comments[idx])
118
+ .collect()
119
+ }
120
+
121
+ /// Gets trailing comments on a specific line (not emitted yet).
122
+ ///
123
+ /// Trailing comments are comments on the same line as code.
124
+ pub fn get_trailing_comments(&self, line: usize) -> Vec<&Comment> {
125
+ self.comments_by_line
126
+ .get(&line)
127
+ .map(|indices| {
128
+ indices
129
+ .iter()
130
+ .filter(|&&idx| !self.emitted_comment_indices.contains(&idx))
131
+ .map(|&idx| &self.all_comments[idx])
132
+ .collect()
133
+ })
134
+ .unwrap_or_default()
135
+ }
136
+
137
+ /// Gets comments within a given line range [start_line, end_line).
138
+ ///
139
+ /// Only returns comments that haven't been emitted yet.
140
+ pub fn get_comments_in_range(&self, start_line: usize, end_line: usize) -> Vec<&Comment> {
141
+ if start_line >= end_line {
142
+ return Vec::new();
143
+ }
144
+
145
+ self.comments_by_line
146
+ .range(start_line..end_line)
147
+ .flat_map(|(_, indices)| indices.iter())
148
+ .filter(|&&idx| {
149
+ !self.emitted_comment_indices.contains(&idx)
150
+ && self.all_comments[idx].location.end_line < end_line
151
+ })
152
+ .map(|&idx| &self.all_comments[idx])
153
+ .collect()
154
+ }
155
+
156
+ /// Checks if there are any unemitted comments in the given line range.
157
+ pub fn has_comments_in_range(&self, start_line: usize, end_line: usize) -> bool {
158
+ if start_line >= end_line {
159
+ return false;
160
+ }
161
+
162
+ self.comments_by_line
163
+ .range(start_line..end_line)
164
+ .flat_map(|(_, indices)| indices.iter())
165
+ .any(|&idx| {
166
+ !self.emitted_comment_indices.contains(&idx)
167
+ && self.all_comments[idx].location.end_line < end_line
168
+ })
169
+ }
170
+
171
+ /// Marks a comment as emitted by finding it in the collection.
172
+ ///
173
+ /// Uses the line index for O(log n) lookup instead of linear search.
174
+ pub fn mark_comment_emitted(&mut self, comment: &Comment) {
175
+ if let Some(indices) = self.comments_by_line.get(&comment.location.start_line) {
176
+ for &idx in indices {
177
+ let c = &self.all_comments[idx];
178
+ if c.location == comment.location && c.text == comment.text {
179
+ self.emitted_comment_indices.insert(idx);
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /// Marks a comment at the given index as emitted.
187
+ #[inline]
188
+ pub fn mark_comment_emitted_by_index(&mut self, idx: usize) {
189
+ self.emitted_comment_indices.insert(idx);
190
+ }
191
+
192
+ /// Marks multiple comments as emitted by their indices.
193
+ ///
194
+ /// More efficient than calling mark_comment_emitted_by_index repeatedly.
195
+ pub fn mark_comments_emitted(&mut self, indices: impl IntoIterator<Item = usize>) {
196
+ self.emitted_comment_indices.extend(indices);
197
+ }
198
+
199
+ /// Extracts source text for a node.
200
+ pub fn extract_source(&self, node: &Node) -> Option<&str> {
201
+ self.source
202
+ .get(node.location.start_offset..node.location.end_offset)
203
+ }
204
+
205
+ /// Extracts source text for a range of offsets.
206
+ pub fn extract_source_range(&self, start: usize, end: usize) -> Option<&str> {
207
+ self.source.get(start..end)
208
+ }
209
+
210
+ /// Checks if a comment is standalone (on its own line).
211
+ ///
212
+ /// A standalone comment has only whitespace before it on the same line.
213
+ pub fn is_standalone_comment(&self, comment: &Comment) -> bool {
214
+ let comment_line = comment.location.start_line;
215
+
216
+ if comment_line == 0 || comment_line > self.source_lines.len() {
217
+ return false;
218
+ }
219
+
220
+ let line = self.source_lines[comment_line - 1]; // Convert to 0-indexed
221
+
222
+ if let Some(hash_pos) = line.find('#') {
223
+ let before_comment = &line[..hash_pos];
224
+ let is_only_whitespace = before_comment.bytes().all(|b| b == b' ' || b == b'\t');
225
+
226
+ let line_comment_text = &line[hash_pos..];
227
+ let is_same_comment = line_comment_text.trim_end() == comment.text.trim_end();
228
+
229
+ return is_only_whitespace && is_same_comment;
230
+ }
231
+
232
+ false
233
+ }
234
+
235
+ /// Gets all remaining unemitted comments.
236
+ ///
237
+ /// Used for emitting comments at the end of the file.
238
+ pub fn get_remaining_comments(&self) -> Vec<&Comment> {
239
+ self.all_comments
240
+ .iter()
241
+ .enumerate()
242
+ .filter(|(idx, _)| !self.emitted_comment_indices.contains(idx))
243
+ .map(|(_, comment)| comment)
244
+ .collect()
245
+ }
246
+
247
+ /// Gets comment indices before a given line (not emitted yet).
248
+ ///
249
+ /// Returns indices that can be used with `get_comment` and `mark_comment_emitted_by_index`.
250
+ /// This avoids allocating comment data when only indices are needed.
251
+ pub fn get_comment_indices_before(&self, line: usize) -> impl Iterator<Item = usize> + '_ {
252
+ self.comments_by_line
253
+ .range(..line)
254
+ .flat_map(|(_, indices)| indices.iter().copied())
255
+ .filter(move |&idx| {
256
+ !self.emitted_comment_indices.contains(&idx)
257
+ && self.all_comments[idx].location.end_line < line
258
+ })
259
+ }
260
+
261
+ /// Gets trailing comment indices on a specific line (not emitted yet).
262
+ pub fn get_trailing_comment_indices(&self, line: usize) -> impl Iterator<Item = usize> + '_ {
263
+ self.comments_by_line
264
+ .get(&line)
265
+ .into_iter()
266
+ .flat_map(|indices| indices.iter().copied())
267
+ .filter(move |&idx| !self.emitted_comment_indices.contains(&idx))
268
+ }
269
+
270
+ /// Gets comment indices within a given line range [start_line, end_line).
271
+ ///
272
+ /// Returns an empty iterator if start_line >= end_line.
273
+ /// This is consistent with `get_comments_in_range` and `has_comments_in_range`.
274
+ pub fn get_comment_indices_in_range(
275
+ &self,
276
+ start_line: usize,
277
+ end_line: usize,
278
+ ) -> impl Iterator<Item = usize> + '_ {
279
+ // Return empty iterator for invalid range (consistent with get_comments_in_range)
280
+ let valid_range = start_line < end_line;
281
+
282
+ self.comments_by_line
283
+ .range(start_line..end_line.max(start_line))
284
+ .flat_map(|(_, indices)| indices.iter().copied())
285
+ .filter(move |&idx| {
286
+ valid_range
287
+ && !self.emitted_comment_indices.contains(&idx)
288
+ && self.all_comments[idx].location.end_line < end_line
289
+ })
290
+ }
291
+
292
+ /// Gets remaining comment indices (not emitted yet).
293
+ pub fn get_remaining_comment_indices(&self) -> impl Iterator<Item = usize> + '_ {
294
+ (0..self.all_comments.len()).filter(|idx| !self.emitted_comment_indices.contains(idx))
295
+ }
296
+
297
+ /// Gets a comment by index.
298
+ #[inline]
299
+ pub fn get_comment(&self, idx: usize) -> Option<&Comment> {
300
+ self.all_comments.get(idx)
301
+ }
302
+
303
+ /// Gets the last line of code in the AST (excluding comments).
304
+ pub fn find_last_code_line(ast: &Node) -> usize {
305
+ let mut max_line = ast.location.end_line;
306
+ let mut stack = vec![ast];
307
+
308
+ while let Some(node) = stack.pop() {
309
+ max_line = max_line.max(node.location.end_line);
310
+ stack.extend(node.children.iter());
311
+ }
312
+
313
+ max_line
314
+ }
315
+ }
316
+
317
+ #[cfg(test)]
318
+ mod tests {
319
+ use super::*;
320
+ use crate::ast::{CommentPosition, CommentType, FormattingInfo, Location, NodeType};
321
+ use std::collections::HashMap;
322
+
323
+ fn make_comment(text: &str, start_line: usize) -> Comment {
324
+ Comment {
325
+ text: text.to_string(),
326
+ location: Location::new(start_line, 0, start_line, text.len(), 0, text.len()),
327
+ comment_type: CommentType::Line,
328
+ position: CommentPosition::Leading,
329
+ }
330
+ }
331
+
332
+ fn make_node_with_comments(comments: Vec<Comment>) -> Node {
333
+ Node {
334
+ node_type: NodeType::ProgramNode,
335
+ location: Location::new(1, 0, 10, 0, 0, 100),
336
+ children: Vec::new(),
337
+ metadata: HashMap::new(),
338
+ comments,
339
+ formatting: FormattingInfo::default(),
340
+ }
341
+ }
342
+
343
+ #[test]
344
+ fn test_collect_comments() {
345
+ let config = Config::default();
346
+ let source = "# comment\nclass Foo\nend";
347
+ let mut ctx = FormatContext::new(&config, source);
348
+
349
+ let comment = make_comment("# comment", 1);
350
+ let node = make_node_with_comments(vec![comment]);
351
+
352
+ ctx.collect_comments(&node);
353
+
354
+ let comments = ctx.get_comments_before(10);
355
+ assert_eq!(comments.len(), 1);
356
+ assert_eq!(comments[0].text, "# comment");
357
+ }
358
+
359
+ #[test]
360
+ fn test_mark_comment_emitted() {
361
+ let config = Config::default();
362
+ let source = "# comment\ncode";
363
+ let mut ctx = FormatContext::new(&config, source);
364
+
365
+ let comment = make_comment("# comment", 1);
366
+ let node = make_node_with_comments(vec![comment.clone()]);
367
+
368
+ ctx.collect_comments(&node);
369
+
370
+ // Before marking
371
+ assert_eq!(ctx.get_comments_before(10).len(), 1);
372
+
373
+ // Mark as emitted
374
+ ctx.mark_comment_emitted(&comment);
375
+
376
+ // After marking
377
+ assert_eq!(ctx.get_comments_before(10).len(), 0);
378
+ }
379
+
380
+ #[test]
381
+ fn test_get_comments_in_range() {
382
+ let config = Config::default();
383
+ let source = "# comment\ncode";
384
+ let mut ctx = FormatContext::new(&config, source);
385
+
386
+ let comment1 = make_comment("# comment 1", 2);
387
+ let comment2 = make_comment("# comment 2", 5);
388
+ let comment3 = make_comment("# comment 3", 8);
389
+ let node = make_node_with_comments(vec![comment1, comment2, comment3]);
390
+
391
+ ctx.collect_comments(&node);
392
+
393
+ let comments = ctx.get_comments_in_range(3, 7);
394
+ assert_eq!(comments.len(), 1);
395
+ assert_eq!(comments[0].text, "# comment 2");
396
+ }
397
+
398
+ #[test]
399
+ fn test_trailing_comments() {
400
+ let config = Config::default();
401
+ let source = "code # trailing";
402
+ let mut ctx = FormatContext::new(&config, source);
403
+
404
+ let comment = Comment {
405
+ text: "# trailing".to_string(),
406
+ location: Location::new(1, 5, 1, 15, 5, 15),
407
+ comment_type: CommentType::Line,
408
+ position: CommentPosition::Trailing,
409
+ };
410
+ let node = make_node_with_comments(vec![comment]);
411
+
412
+ ctx.collect_comments(&node);
413
+
414
+ let trailing = ctx.get_trailing_comments(1);
415
+ assert_eq!(trailing.len(), 1);
416
+ assert_eq!(trailing[0].text, "# trailing");
417
+ }
418
+
419
+ #[test]
420
+ fn test_next_group_id() {
421
+ let config = Config::default();
422
+ let source = "";
423
+ let mut ctx = FormatContext::new(&config, source);
424
+
425
+ assert_eq!(ctx.next_group_id(), 0);
426
+ assert_eq!(ctx.next_group_id(), 1);
427
+ assert_eq!(ctx.next_group_id(), 2);
428
+ }
429
+
430
+ #[test]
431
+ fn test_extract_source() {
432
+ let config = Config::default();
433
+ let source = "class Foo\nend";
434
+ let ctx = FormatContext::new(&config, source);
435
+
436
+ let node = Node {
437
+ node_type: NodeType::ClassNode,
438
+ location: Location::new(1, 0, 2, 3, 0, 13),
439
+ children: Vec::new(),
440
+ metadata: HashMap::new(),
441
+ comments: Vec::new(),
442
+ formatting: FormattingInfo::default(),
443
+ };
444
+
445
+ let extracted = ctx.extract_source(&node);
446
+ assert_eq!(extracted, Some("class Foo\nend"));
447
+ }
448
+ }
@@ -0,0 +1,226 @@
1
+ //! Formatter - Main entry point for the rule-based formatting system
2
+ //!
3
+ //! The Formatter coordinates the formatting process:
4
+ //! 1. Initialize FormatContext with source and config
5
+ //! 2. Collect comments from AST
6
+ //! 3. Apply rules to generate Doc IR
7
+ //! 4. Print Doc IR to string using Printer
8
+
9
+ use crate::ast::{Node, NodeType};
10
+ use crate::config::Config;
11
+ use crate::doc::{concat, hardline, Doc, Printer};
12
+ use crate::error::Result;
13
+
14
+ use super::context::FormatContext;
15
+ use super::registry::RuleRegistry;
16
+ use super::rule::format_remaining_comments;
17
+
18
+ /// Main formatter that coordinates the formatting process.
19
+ ///
20
+ /// The formatter uses a rule-based architecture where each node type
21
+ /// can have a specific formatting rule. Unhandled node types fall back
22
+ /// to source extraction.
23
+ pub struct Formatter {
24
+ /// Configuration for formatting
25
+ config: Config,
26
+ /// Registry of formatting rules
27
+ registry: RuleRegistry,
28
+ }
29
+
30
+ impl Formatter {
31
+ /// Creates a new formatter with the given configuration.
32
+ pub fn new(config: Config) -> Self {
33
+ Self {
34
+ config,
35
+ registry: RuleRegistry::default_registry(),
36
+ }
37
+ }
38
+
39
+ /// Creates a new formatter with a custom registry.
40
+ pub fn with_registry(config: Config, registry: RuleRegistry) -> Self {
41
+ Self { config, registry }
42
+ }
43
+
44
+ /// Formats Ruby source code.
45
+ ///
46
+ /// # Arguments
47
+ /// * `source` - The original Ruby source code
48
+ /// * `ast` - The parsed AST root node
49
+ ///
50
+ /// # Returns
51
+ /// The formatted source code as a string
52
+ pub fn format(&self, source: &str, ast: &Node) -> Result<String> {
53
+ // 1. Initialize context
54
+ let mut ctx = FormatContext::new(&self.config, source);
55
+
56
+ // 2. Collect comments from AST
57
+ ctx.collect_comments(ast);
58
+
59
+ // 3. Generate Doc IR
60
+ let doc = self.format_node(ast, &mut ctx)?;
61
+
62
+ // 4. Handle remaining comments
63
+ let last_code_line = FormatContext::find_last_code_line(ast);
64
+ let remaining = format_remaining_comments(&mut ctx, last_code_line);
65
+
66
+ let final_doc = if remaining.is_empty() {
67
+ doc
68
+ } else {
69
+ concat(vec![doc, remaining])
70
+ };
71
+
72
+ // 5. Print to string
73
+ let mut printer = Printer::new(&self.config);
74
+ let result = printer.print(&final_doc);
75
+
76
+ Ok(result)
77
+ }
78
+
79
+ /// Formats a single node.
80
+ pub fn format_node(&self, node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
81
+ match &node.node_type {
82
+ NodeType::ProgramNode => self.format_program(node, ctx),
83
+ NodeType::StatementsNode => self.format_statements(node, ctx),
84
+ _ => {
85
+ // Use the rule registry for specific node types
86
+ let rule = self.registry.get_rule(&node.node_type);
87
+ rule.format(node, ctx, &self.registry)
88
+ }
89
+ }
90
+ }
91
+
92
+ /// Returns a reference to the registry for recursive formatting.
93
+ pub fn registry(&self) -> &RuleRegistry {
94
+ &self.registry
95
+ }
96
+
97
+ /// Formats the program node (root).
98
+ fn format_program(&self, node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
99
+ self.format_children_with_spacing(&node.children, ctx)
100
+ }
101
+
102
+ /// Formats a statements node (body of class/module/def).
103
+ fn format_statements(&self, node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
104
+ self.format_children_with_spacing(&node.children, ctx)
105
+ }
106
+
107
+ /// Format a sequence of child nodes with appropriate line breaks.
108
+ fn format_children_with_spacing(
109
+ &self,
110
+ children: &[Node],
111
+ ctx: &mut FormatContext,
112
+ ) -> Result<Doc> {
113
+ if children.is_empty() {
114
+ return Ok(Doc::Empty);
115
+ }
116
+
117
+ let mut docs: Vec<Doc> = Vec::with_capacity(children.len() * 2);
118
+
119
+ for (i, child) in children.iter().enumerate() {
120
+ let child_doc = self.format_node(child, ctx)?;
121
+ docs.push(child_doc);
122
+
123
+ // Add newlines between statements
124
+ if let Some(next_child) = children.get(i + 1) {
125
+ let current_end_line = child.location.end_line;
126
+ let next_start_line = next_child.location.start_line;
127
+ let line_diff = next_start_line.saturating_sub(current_end_line);
128
+
129
+ // Add 1 hardline if consecutive, 2 hardlines (1 blank line) if there was a gap
130
+ docs.push(hardline());
131
+ if line_diff > 1 {
132
+ docs.push(hardline());
133
+ }
134
+ }
135
+ }
136
+
137
+ Ok(concat(docs))
138
+ }
139
+ }
140
+
141
+ impl Default for Formatter {
142
+ fn default() -> Self {
143
+ Self::new(Config::default())
144
+ }
145
+ }
146
+
147
+ #[cfg(test)]
148
+ mod tests {
149
+ use super::*;
150
+ use crate::ast::{FormattingInfo, Location};
151
+ use std::collections::HashMap;
152
+
153
+ fn make_program_node(children: Vec<Node>, end_line: usize) -> Node {
154
+ Node {
155
+ node_type: NodeType::ProgramNode,
156
+ location: Location::new(1, 0, end_line, 0, 0, 100),
157
+ children,
158
+ metadata: HashMap::new(),
159
+ comments: Vec::new(),
160
+ formatting: FormattingInfo::default(),
161
+ }
162
+ }
163
+
164
+ fn make_class_node(
165
+ name: &str,
166
+ start_line: usize,
167
+ end_line: usize,
168
+ start_offset: usize,
169
+ end_offset: usize,
170
+ ) -> Node {
171
+ let mut metadata = HashMap::new();
172
+ metadata.insert("name".to_string(), name.to_string());
173
+
174
+ Node {
175
+ node_type: NodeType::ClassNode,
176
+ location: Location::new(start_line, 0, end_line, 3, start_offset, end_offset),
177
+ children: Vec::new(),
178
+ metadata,
179
+ comments: Vec::new(),
180
+ formatting: FormattingInfo::default(),
181
+ }
182
+ }
183
+
184
+ #[test]
185
+ fn test_format_simple_class() {
186
+ let source = "class Foo\nend";
187
+ let class_node = make_class_node("Foo", 1, 2, 0, 13);
188
+ let ast = make_program_node(vec![class_node], 2);
189
+
190
+ let formatter = Formatter::default();
191
+ let result = formatter.format(source, &ast).unwrap();
192
+
193
+ assert_eq!(result, "class Foo\nend\n");
194
+ }
195
+
196
+ #[test]
197
+ fn test_format_multiple_classes() {
198
+ let source = "class Foo\nend\n\nclass Bar\nend";
199
+ let class1 = make_class_node("Foo", 1, 2, 0, 13);
200
+ let class2 = make_class_node("Bar", 4, 5, 15, 28);
201
+ let ast = make_program_node(vec![class1, class2], 5);
202
+
203
+ let formatter = Formatter::default();
204
+ let result = formatter.format(source, &ast).unwrap();
205
+
206
+ // Should preserve blank line between classes
207
+ assert!(result.contains("class Foo\nend"));
208
+ assert!(result.contains("class Bar\nend"));
209
+ assert!(result.contains("\n\n")); // blank line preserved
210
+ }
211
+
212
+ #[test]
213
+ fn test_formatter_with_custom_config() {
214
+ let mut config = Config::default();
215
+ config.formatting.indent_width = 4;
216
+
217
+ let source = "class Foo\nend";
218
+ let class_node = make_class_node("Foo", 1, 2, 0, 13);
219
+ let ast = make_program_node(vec![class_node], 2);
220
+
221
+ let formatter = Formatter::new(config);
222
+ let result = formatter.format(source, &ast).unwrap();
223
+
224
+ assert_eq!(result, "class Foo\nend\n");
225
+ }
226
+ }