rfmt 0.1.0 → 0.2.1

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,407 @@
1
+ use crate::ast::{Comment, CommentPosition, CommentType, FormattingInfo, Location, Node, NodeType};
2
+ use crate::error::{Result, RfmtError};
3
+ use crate::parser::RubyParser;
4
+ use serde::{Deserialize, Serialize};
5
+ use std::collections::HashMap;
6
+
7
+ /// Prism parser adapter
8
+ /// This integrates with Ruby Prism parser via Magnus FFI
9
+ pub struct PrismAdapter;
10
+
11
+ impl PrismAdapter {
12
+ pub fn new() -> Self {
13
+ Self
14
+ }
15
+
16
+ /// Parse JSON from Ruby's PrismBridge
17
+ fn parse_json(&self, json: &str) -> Result<(PrismNode, Vec<PrismComment>)> {
18
+ // Try to parse as new format with comments first
19
+ if let Ok(wrapper) = serde_json::from_str::<PrismWrapper>(json) {
20
+ return Ok((wrapper.ast, wrapper.comments));
21
+ }
22
+
23
+ // Fall back to old format (single node without comments)
24
+ let node: PrismNode = serde_json::from_str(json)
25
+ .map_err(|e| RfmtError::PrismError(format!("Failed to parse Prism JSON: {}", e)))?;
26
+ Ok((node, Vec::new()))
27
+ }
28
+
29
+ /// Convert PrismNode to internal Node representation
30
+ fn convert_node(&self, prism_node: &PrismNode) -> Result<Node> {
31
+ // Convert node type (always succeeds, returns Unknown for unsupported types)
32
+ let node_type = NodeType::from_str(&prism_node.node_type);
33
+
34
+ // Convert location
35
+ let location = Location::new(
36
+ prism_node.location.start_line,
37
+ prism_node.location.start_column,
38
+ prism_node.location.end_line,
39
+ prism_node.location.end_column,
40
+ prism_node.location.start_offset,
41
+ prism_node.location.end_offset,
42
+ );
43
+
44
+ // Convert children recursively
45
+ let children: Result<Vec<Node>> = prism_node
46
+ .children
47
+ .iter()
48
+ .map(|child| self.convert_node(child))
49
+ .collect();
50
+ let children = children?;
51
+
52
+ // Convert comments
53
+ let comments: Vec<Comment> = prism_node
54
+ .comments
55
+ .iter()
56
+ .map(|c| self.convert_comment(c))
57
+ .collect();
58
+
59
+ // Convert formatting info
60
+ let formatting = FormattingInfo {
61
+ indent_level: prism_node.formatting.indent_level,
62
+ needs_blank_line_before: prism_node.formatting.needs_blank_line_before,
63
+ needs_blank_line_after: prism_node.formatting.needs_blank_line_after,
64
+ preserve_newlines: prism_node.formatting.preserve_newlines,
65
+ multiline: prism_node.formatting.multiline,
66
+ original_formatting: prism_node.formatting.original_formatting.clone(),
67
+ };
68
+
69
+ Ok(Node {
70
+ node_type,
71
+ location,
72
+ children,
73
+ metadata: prism_node.metadata.clone(),
74
+ comments,
75
+ formatting,
76
+ })
77
+ }
78
+
79
+ /// Convert PrismComment to internal Comment
80
+ fn convert_comment(&self, comment: &PrismComment) -> Comment {
81
+ let comment_type = match comment.comment_type.as_str() {
82
+ "line" => CommentType::Line,
83
+ "block" => CommentType::Block,
84
+ _ => CommentType::Line, // default to line comment
85
+ };
86
+
87
+ let position = match comment.position.as_str() {
88
+ "leading" => CommentPosition::Leading,
89
+ "trailing" => CommentPosition::Trailing,
90
+ "inner" => CommentPosition::Inner,
91
+ _ => CommentPosition::Leading, // default to leading
92
+ };
93
+
94
+ Comment {
95
+ text: comment.text.clone(),
96
+ location: Location::new(
97
+ comment.location.start_line,
98
+ comment.location.start_column,
99
+ comment.location.end_line,
100
+ comment.location.end_column,
101
+ comment.location.start_offset,
102
+ comment.location.end_offset,
103
+ ),
104
+ comment_type,
105
+ position,
106
+ }
107
+ }
108
+ }
109
+
110
+ impl RubyParser for PrismAdapter {
111
+ fn parse(&self, json: &str) -> Result<Node> {
112
+ let (prism_ast, top_level_comments) = self.parse_json(json)?;
113
+ let mut node = self.convert_node(&prism_ast)?;
114
+
115
+ // Attach top-level comments to the root node
116
+ if !top_level_comments.is_empty() {
117
+ node.comments
118
+ .extend(top_level_comments.iter().map(|c| self.convert_comment(c)));
119
+ }
120
+
121
+ Ok(node)
122
+ }
123
+ }
124
+
125
+ impl Default for PrismAdapter {
126
+ fn default() -> Self {
127
+ Self::new()
128
+ }
129
+ }
130
+
131
+ /// Wrapper for JSON containing both AST and comments
132
+ #[derive(Debug, Clone, Serialize, Deserialize)]
133
+ pub struct PrismWrapper {
134
+ pub ast: PrismNode,
135
+ pub comments: Vec<PrismComment>,
136
+ }
137
+
138
+ /// JSON representation of a Prism node from Ruby
139
+ #[derive(Debug, Clone, Serialize, Deserialize)]
140
+ pub struct PrismNode {
141
+ pub node_type: String,
142
+ pub location: PrismLocation,
143
+ pub children: Vec<PrismNode>,
144
+ pub metadata: HashMap<String, String>,
145
+ pub comments: Vec<PrismComment>,
146
+ pub formatting: PrismFormattingInfo,
147
+ }
148
+
149
+ #[derive(Debug, Clone, Serialize, Deserialize)]
150
+ pub struct PrismLocation {
151
+ pub start_line: usize,
152
+ pub start_column: usize,
153
+ pub end_line: usize,
154
+ pub end_column: usize,
155
+ pub start_offset: usize,
156
+ pub end_offset: usize,
157
+ }
158
+
159
+ #[derive(Debug, Clone, Serialize, Deserialize)]
160
+ pub struct PrismComment {
161
+ pub text: String,
162
+ pub location: PrismLocation,
163
+ pub comment_type: String,
164
+ pub position: String,
165
+ }
166
+
167
+ #[derive(Debug, Clone, Serialize, Deserialize)]
168
+ pub struct PrismFormattingInfo {
169
+ pub indent_level: usize,
170
+ pub needs_blank_line_before: bool,
171
+ pub needs_blank_line_after: bool,
172
+ pub preserve_newlines: bool,
173
+ pub multiline: bool,
174
+ pub original_formatting: Option<String>,
175
+ }
176
+
177
+ #[cfg(test)]
178
+ mod tests {
179
+ use super::*;
180
+
181
+ #[test]
182
+ fn test_parse_simple_program() {
183
+ let adapter = PrismAdapter::new();
184
+ let json = r#"{
185
+ "node_type": "program_node",
186
+ "location": {
187
+ "start_line": 1,
188
+ "start_column": 0,
189
+ "end_line": 1,
190
+ "end_column": 12,
191
+ "start_offset": 0,
192
+ "end_offset": 12
193
+ },
194
+ "children": [],
195
+ "metadata": {},
196
+ "comments": [],
197
+ "formatting": {
198
+ "indent_level": 0,
199
+ "needs_blank_line_before": false,
200
+ "needs_blank_line_after": false,
201
+ "preserve_newlines": false,
202
+ "multiline": false,
203
+ "original_formatting": null
204
+ }
205
+ }"#;
206
+
207
+ let result = adapter.parse(json);
208
+ assert!(result.is_ok());
209
+
210
+ let node = result.unwrap();
211
+ assert_eq!(node.node_type, NodeType::ProgramNode);
212
+ assert_eq!(node.location.start_line, 1);
213
+ assert_eq!(node.location.end_line, 1);
214
+ }
215
+
216
+ #[test]
217
+ fn test_parse_with_children() {
218
+ let adapter = PrismAdapter::new();
219
+ let json = r#"{
220
+ "node_type": "program_node",
221
+ "location": {
222
+ "start_line": 1,
223
+ "start_column": 0,
224
+ "end_line": 1,
225
+ "end_column": 14,
226
+ "start_offset": 0,
227
+ "end_offset": 14
228
+ },
229
+ "children": [
230
+ {
231
+ "node_type": "class_node",
232
+ "location": {
233
+ "start_line": 1,
234
+ "start_column": 0,
235
+ "end_line": 1,
236
+ "end_column": 14,
237
+ "start_offset": 0,
238
+ "end_offset": 14
239
+ },
240
+ "children": [],
241
+ "metadata": {"name": "Foo"},
242
+ "comments": [],
243
+ "formatting": {
244
+ "indent_level": 0,
245
+ "needs_blank_line_before": false,
246
+ "needs_blank_line_after": false,
247
+ "preserve_newlines": false,
248
+ "multiline": false,
249
+ "original_formatting": null
250
+ }
251
+ }
252
+ ],
253
+ "metadata": {},
254
+ "comments": [],
255
+ "formatting": {
256
+ "indent_level": 0,
257
+ "needs_blank_line_before": false,
258
+ "needs_blank_line_after": false,
259
+ "preserve_newlines": false,
260
+ "multiline": false,
261
+ "original_formatting": null
262
+ }
263
+ }"#;
264
+
265
+ let result = adapter.parse(json);
266
+ assert!(result.is_ok());
267
+
268
+ let node = result.unwrap();
269
+ assert_eq!(node.node_type, NodeType::ProgramNode);
270
+ assert_eq!(node.children.len(), 1);
271
+
272
+ let child = &node.children[0];
273
+ assert_eq!(child.node_type, NodeType::ClassNode);
274
+ assert_eq!(child.metadata.get("name"), Some(&"Foo".to_string()));
275
+ }
276
+
277
+ #[test]
278
+ fn test_parse_with_metadata() {
279
+ let adapter = PrismAdapter::new();
280
+ let json = r#"{
281
+ "node_type": "def_node",
282
+ "location": {
283
+ "start_line": 1,
284
+ "start_column": 0,
285
+ "end_line": 1,
286
+ "end_column": 10,
287
+ "start_offset": 0,
288
+ "end_offset": 10
289
+ },
290
+ "children": [],
291
+ "metadata": {
292
+ "name": "hello",
293
+ "parameters_count": "1"
294
+ },
295
+ "comments": [],
296
+ "formatting": {
297
+ "indent_level": 0,
298
+ "needs_blank_line_before": false,
299
+ "needs_blank_line_after": false,
300
+ "preserve_newlines": false,
301
+ "multiline": false,
302
+ "original_formatting": null
303
+ }
304
+ }"#;
305
+
306
+ let result = adapter.parse(json);
307
+ assert!(result.is_ok());
308
+
309
+ let node = result.unwrap();
310
+ assert_eq!(node.node_type, NodeType::DefNode);
311
+ assert_eq!(node.metadata.get("name"), Some(&"hello".to_string()));
312
+ assert_eq!(
313
+ node.metadata.get("parameters_count"),
314
+ Some(&"1".to_string())
315
+ );
316
+ }
317
+
318
+ #[test]
319
+ fn test_parse_multiline() {
320
+ let adapter = PrismAdapter::new();
321
+ let json = r#"{
322
+ "node_type": "class_node",
323
+ "location": {
324
+ "start_line": 1,
325
+ "start_column": 0,
326
+ "end_line": 3,
327
+ "end_column": 3,
328
+ "start_offset": 0,
329
+ "end_offset": 20
330
+ },
331
+ "children": [],
332
+ "metadata": {"name": "Foo"},
333
+ "comments": [],
334
+ "formatting": {
335
+ "indent_level": 0,
336
+ "needs_blank_line_before": false,
337
+ "needs_blank_line_after": false,
338
+ "preserve_newlines": false,
339
+ "multiline": true,
340
+ "original_formatting": null
341
+ }
342
+ }"#;
343
+
344
+ let result = adapter.parse(json);
345
+ assert!(result.is_ok());
346
+
347
+ let node = result.unwrap();
348
+ assert_eq!(node.node_type, NodeType::ClassNode);
349
+ assert_eq!(node.formatting.multiline, true);
350
+ assert!(node.is_multiline());
351
+ assert_eq!(node.line_count(), 3);
352
+ }
353
+
354
+ #[test]
355
+ fn test_parse_invalid_json() {
356
+ let adapter = PrismAdapter::new();
357
+ let json = "invalid json";
358
+
359
+ let result = adapter.parse(json);
360
+ assert!(result.is_err());
361
+
362
+ match result {
363
+ Err(RfmtError::PrismError(msg)) => {
364
+ assert!(msg.contains("Failed to parse Prism JSON"));
365
+ }
366
+ _ => panic!("Expected PrismError"),
367
+ }
368
+ }
369
+
370
+ #[test]
371
+ fn test_parse_unknown_node_type() {
372
+ let adapter = PrismAdapter::new();
373
+ let json = r#"{
374
+ "node_type": "totally_unknown_node",
375
+ "location": {
376
+ "start_line": 1,
377
+ "start_column": 0,
378
+ "end_line": 1,
379
+ "end_column": 10,
380
+ "start_offset": 0,
381
+ "end_offset": 10
382
+ },
383
+ "children": [],
384
+ "metadata": {},
385
+ "comments": [],
386
+ "formatting": {
387
+ "indent_level": 0,
388
+ "needs_blank_line_before": false,
389
+ "needs_blank_line_after": false,
390
+ "preserve_newlines": false,
391
+ "multiline": false,
392
+ "original_formatting": null
393
+ }
394
+ }"#;
395
+
396
+ let result = adapter.parse(json);
397
+ assert!(result.is_ok());
398
+
399
+ let node = result.unwrap();
400
+ assert_eq!(
401
+ node.node_type,
402
+ NodeType::Unknown("totally_unknown_node".to_string())
403
+ );
404
+ assert!(node.is_unknown());
405
+ assert_eq!(node.unknown_type(), Some("totally_unknown_node"));
406
+ }
407
+ }
@@ -0,0 +1,36 @@
1
+ pub mod validation;
2
+
3
+ use crate::error::Result;
4
+
5
+ /// Security policy for rfmt operations
6
+ #[derive(Debug, Clone)]
7
+ pub struct SecurityPolicy {
8
+ /// Maximum file size in bytes (default: 10MB)
9
+ pub max_file_size: u64,
10
+ }
11
+
12
+ impl SecurityPolicy {
13
+ /// Validate source code size
14
+ pub fn validate_source_size(&self, source: &str) -> Result<()> {
15
+ validation::validate_source_size(source, self.max_file_size)
16
+ }
17
+ }
18
+
19
+ impl Default for SecurityPolicy {
20
+ fn default() -> Self {
21
+ Self {
22
+ max_file_size: 10 * 1024 * 1024, // 10MB
23
+ }
24
+ }
25
+ }
26
+
27
+ #[cfg(test)]
28
+ mod tests {
29
+ use super::*;
30
+
31
+ #[test]
32
+ fn test_default_policy() {
33
+ let policy = SecurityPolicy::default();
34
+ assert_eq!(policy.max_file_size, 10 * 1024 * 1024);
35
+ }
36
+ }
@@ -0,0 +1,18 @@
1
+ use crate::error::{Result, RfmtError};
2
+
3
+ /// Validate source code size
4
+ pub fn validate_source_size(source: &str, max_size: u64) -> Result<()> {
5
+ let size = source.len() as u64;
6
+
7
+ if size > max_size {
8
+ return Err(RfmtError::UnsupportedFeature {
9
+ feature: "Large file".to_string(),
10
+ explanation: format!(
11
+ "Source code is too large ({} bytes, max {} bytes)",
12
+ size, max_size
13
+ ),
14
+ });
15
+ }
16
+
17
+ Ok(())
18
+ }
data/lib/rfmt/cache.rb ADDED
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module Rfmt
8
+ # Cache system for formatted files
9
+ # Uses SHA256 hash of file content to determine if formatting is needed
10
+ class Cache
11
+ class CacheError < StandardError; end
12
+
13
+ DEFAULT_CACHE_DIR = File.expand_path('~/.cache/rfmt').freeze
14
+ CACHE_VERSION = '1'
15
+
16
+ attr_reader :cache_dir
17
+
18
+ def initialize(cache_dir: DEFAULT_CACHE_DIR)
19
+ @cache_dir = cache_dir
20
+ @cache_data = {}
21
+ ensure_cache_dir
22
+ load_cache
23
+ end
24
+
25
+ # Check if file needs formatting
26
+ # Returns true if file content has changed or not in cache
27
+ def needs_formatting?(file_path)
28
+ return true unless File.exist?(file_path)
29
+
30
+ current_hash = file_hash(file_path)
31
+ cached_hash = @cache_data.dig(file_path, 'hash')
32
+
33
+ current_hash != cached_hash
34
+ end
35
+
36
+ # Mark file as formatted with current content
37
+ def mark_formatted(file_path)
38
+ return unless File.exist?(file_path)
39
+
40
+ @cache_data[file_path] = {
41
+ 'hash' => file_hash(file_path),
42
+ 'formatted_at' => Time.now.to_i,
43
+ 'version' => CACHE_VERSION
44
+ }
45
+ end
46
+
47
+ # Save cache to disk
48
+ def save
49
+ cache_file = File.join(@cache_dir, 'cache.json')
50
+ File.write(cache_file, JSON.pretty_generate(@cache_data))
51
+ end
52
+
53
+ # Clear all cache data
54
+ def clear
55
+ @cache_data = {}
56
+ save
57
+ end
58
+
59
+ # Remove cache for specific file
60
+ def invalidate(file_path)
61
+ @cache_data.delete(file_path)
62
+ end
63
+
64
+ # Get cache statistics
65
+ def stats
66
+ {
67
+ total_files: @cache_data.size,
68
+ cache_dir: @cache_dir,
69
+ cache_size_bytes: cache_size
70
+ }
71
+ end
72
+
73
+ # Prune old cache entries (files that no longer exist)
74
+ def prune
75
+ before_count = @cache_data.size
76
+ @cache_data.delete_if { |file_path, _| !File.exist?(file_path) }
77
+ after_count = @cache_data.size
78
+ pruned = before_count - after_count
79
+
80
+ save if pruned.positive?
81
+ pruned
82
+ end
83
+
84
+ private
85
+
86
+ def ensure_cache_dir
87
+ FileUtils.mkdir_p(@cache_dir)
88
+ rescue StandardError => e
89
+ raise CacheError, "Failed to create cache directory: #{e.message}"
90
+ end
91
+
92
+ def load_cache
93
+ cache_file = File.join(@cache_dir, 'cache.json')
94
+ return unless File.exist?(cache_file)
95
+
96
+ content = File.read(cache_file)
97
+ @cache_data = JSON.parse(content)
98
+ rescue JSON::ParserError => e
99
+ warn "Warning: Failed to parse cache file, starting with empty cache: #{e.message}"
100
+ @cache_data = {}
101
+ rescue StandardError => e
102
+ warn "Warning: Failed to load cache, starting with empty cache: #{e.message}"
103
+ @cache_data = {}
104
+ end
105
+
106
+ def file_hash(file_path)
107
+ content = File.read(file_path)
108
+ Digest::SHA256.hexdigest(content)
109
+ rescue StandardError => e
110
+ raise CacheError, "Failed to read file #{file_path}: #{e.message}"
111
+ end
112
+
113
+ def cache_size
114
+ cache_file = File.join(@cache_dir, 'cache.json')
115
+ return 0 unless File.exist?(cache_file)
116
+
117
+ File.size(cache_file)
118
+ end
119
+ end
120
+ end