girb 0.1.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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../session_history"
5
+
6
+ module Girb
7
+ module Tools
8
+ class GetSource < Base
9
+ MAX_SOURCE_LINES = 50
10
+
11
+ class << self
12
+ def description
13
+ "Get the source code of a method or class definition. Use 'Class#method' for instance methods, 'Class.method' for class methods."
14
+ end
15
+
16
+ def parameters
17
+ {
18
+ type: "object",
19
+ properties: {
20
+ target: {
21
+ type: "string",
22
+ description: "Class or method to get source for (e.g., 'User', 'User#save', 'User.find')"
23
+ }
24
+ },
25
+ required: ["target"]
26
+ }
27
+ end
28
+ end
29
+
30
+ def execute(binding, target:)
31
+ if target.include?("#")
32
+ get_instance_method_source(binding, target)
33
+ elsif target.include?(".")
34
+ get_class_method_source(binding, target)
35
+ else
36
+ get_class_info(binding, target)
37
+ end
38
+ rescue NameError => e
39
+ { error: "Not found: #{e.message}" }
40
+ rescue StandardError => e
41
+ { error: "#{e.class}: #{e.message}" }
42
+ end
43
+
44
+ private
45
+
46
+ def get_instance_method_source(binding, target)
47
+ class_name, method_name = target.split("#", 2)
48
+ klass = binding.eval(class_name)
49
+ method = klass.instance_method(method_name.to_sym)
50
+
51
+ extract_method_info(method, target)
52
+ end
53
+
54
+ def get_class_method_source(binding, target)
55
+ class_name, method_name = target.split(".", 2)
56
+ klass = binding.eval(class_name)
57
+ method = klass.method(method_name.to_sym)
58
+
59
+ extract_method_info(method, target)
60
+ end
61
+
62
+ def extract_method_info(method, target)
63
+ location = method.source_location
64
+ method_name = method.name.to_s
65
+
66
+ if location
67
+ file, line = location
68
+
69
+ # IRBで定義されたメソッドの場合、SessionHistoryから取得
70
+ if file == "(irb)" || file&.start_with?("(irb)")
71
+ session_method = SessionHistory.find_method(method_name)
72
+ if session_method
73
+ return {
74
+ target: target,
75
+ file: "(irb)",
76
+ line: "#{session_method.start_line}-#{session_method.end_line}",
77
+ source: session_method.code,
78
+ parameters: method.parameters.map { |type, name| "#{type}: #{name}" },
79
+ defined_in_session: true
80
+ }
81
+ end
82
+ end
83
+
84
+ source = read_source(file, line)
85
+ {
86
+ target: target,
87
+ file: file,
88
+ line: line,
89
+ source: source,
90
+ parameters: method.parameters.map { |type, name| "#{type}: #{name}" }
91
+ }
92
+ else
93
+ {
94
+ target: target,
95
+ error: "Source not available (native or C extension method)",
96
+ parameters: method.parameters.map { |type, name| "#{type}: #{name}" }
97
+ }
98
+ end
99
+ end
100
+
101
+ def get_class_info(binding, class_name)
102
+ klass = binding.eval(class_name)
103
+
104
+ {
105
+ name: klass.name,
106
+ type: klass.class.name,
107
+ ancestors: klass.ancestors.first(10).map(&:to_s),
108
+ instance_methods: klass.instance_methods(false).sort.first(30),
109
+ class_methods: (klass.methods - Class.methods).sort.first(30),
110
+ constants: klass.constants.first(20)
111
+ }
112
+ end
113
+
114
+ def read_source(file, start_line)
115
+ return nil unless File.exist?(file)
116
+
117
+ lines = File.readlines(file)
118
+ end_line = find_method_end(lines, start_line - 1)
119
+ lines[(start_line - 1)..end_line].join
120
+ rescue StandardError => e
121
+ "(Failed to read source: #{e.message})"
122
+ end
123
+
124
+ def find_method_end(lines, start_index)
125
+ # 簡易的なインデント解析でメソッド終端を探す
126
+ return [start_index + MAX_SOURCE_LINES, lines.length - 1].min if start_index >= lines.length
127
+
128
+ base_indent = lines[start_index][/^\s*/].length
129
+ end_keywords = %w[end]
130
+
131
+ (start_index + 1).upto([start_index + MAX_SOURCE_LINES, lines.length - 1].min) do |i|
132
+ line = lines[i]
133
+ next if line.strip.empty? || line.strip.start_with?("#")
134
+
135
+ current_indent = line[/^\s*/].length
136
+ if current_indent <= base_indent && end_keywords.any? { |kw| line.strip.start_with?(kw) }
137
+ return i
138
+ end
139
+ end
140
+
141
+ [start_index + MAX_SOURCE_LINES, lines.length - 1].min
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class InspectObject < Base
8
+ class << self
9
+ def description
10
+ "Inspect a variable or expression in the current context. Returns detailed information about the object."
11
+ end
12
+
13
+ def parameters
14
+ {
15
+ type: "object",
16
+ properties: {
17
+ expression: {
18
+ type: "string",
19
+ description: "The variable name or Ruby expression to inspect (e.g., 'user', 'user.errors', '@items.first')"
20
+ }
21
+ },
22
+ required: ["expression"]
23
+ }
24
+ end
25
+ end
26
+
27
+ def execute(binding, expression:)
28
+ result = binding.eval(expression)
29
+ {
30
+ expression: expression,
31
+ class: result.class.name,
32
+ value: safe_inspect(result),
33
+ instance_variables: extract_instance_variables(result),
34
+ methods_count: result.methods.count
35
+ }
36
+ rescue SyntaxError => e
37
+ { error: "Syntax error: #{e.message}" }
38
+ rescue StandardError => e
39
+ { error: "#{e.class}: #{e.message}" }
40
+ end
41
+
42
+ private
43
+
44
+ def extract_instance_variables(obj)
45
+ obj.instance_variables.to_h do |var|
46
+ value = obj.instance_variable_get(var)
47
+ [var, safe_inspect(value, max_length: 200)]
48
+ end
49
+ rescue StandardError
50
+ {}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class ListMethods < Base
8
+ class << self
9
+ def description
10
+ "List methods available on an object or class. Can filter by pattern."
11
+ end
12
+
13
+ def parameters
14
+ {
15
+ type: "object",
16
+ properties: {
17
+ expression: {
18
+ type: "string",
19
+ description: "The variable name or expression to list methods for"
20
+ },
21
+ pattern: {
22
+ type: "string",
23
+ description: "Optional regex pattern to filter method names (e.g., 'valid', 'save')"
24
+ },
25
+ include_inherited: {
26
+ type: "boolean",
27
+ description: "Whether to include inherited methods (default: false)"
28
+ }
29
+ },
30
+ required: ["expression"]
31
+ }
32
+ end
33
+ end
34
+
35
+ def execute(binding, expression:, pattern: nil, include_inherited: false)
36
+ obj = binding.eval(expression)
37
+
38
+ methods = if obj.is_a?(Class) || obj.is_a?(Module)
39
+ {
40
+ instance_methods: obj.instance_methods(!include_inherited),
41
+ class_methods: obj.methods(!include_inherited)
42
+ }
43
+ else
44
+ {
45
+ methods: obj.methods(!include_inherited)
46
+ }
47
+ end
48
+
49
+ # パターンでフィルタ
50
+ if pattern && !pattern.empty?
51
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
52
+ methods = methods.transform_values do |list|
53
+ list.select { |m| m.to_s.match?(regex) }
54
+ end
55
+ end
56
+
57
+ # ソートして返す
58
+ methods.transform_values(&:sort)
59
+ rescue RegexpError => e
60
+ { error: "Invalid pattern: #{e.message}" }
61
+ rescue StandardError => e
62
+ { error: "#{e.class}: #{e.message}" }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class RailsModelInfo < Base
8
+ class << self
9
+ def available?
10
+ defined?(ActiveRecord::Base)
11
+ end
12
+
13
+ def description
14
+ "Get Rails ActiveRecord model information including associations, validations, callbacks, and scopes."
15
+ end
16
+
17
+ def parameters
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ model_name: {
22
+ type: "string",
23
+ description: "The model class name (e.g., 'User', 'Order', 'Product')"
24
+ }
25
+ },
26
+ required: ["model_name"]
27
+ }
28
+ end
29
+ end
30
+
31
+ def execute(binding, model_name:)
32
+ klass = binding.eval(model_name)
33
+
34
+ unless klass < ActiveRecord::Base
35
+ return { error: "#{model_name} is not an ActiveRecord model" }
36
+ end
37
+
38
+ {
39
+ model: model_name,
40
+ table_name: klass.table_name,
41
+ primary_key: klass.primary_key,
42
+ columns: get_columns(klass),
43
+ associations: get_associations(klass),
44
+ validations: get_validations(klass),
45
+ callbacks: get_callbacks(klass),
46
+ scopes: get_scopes(klass)
47
+ }
48
+ rescue NameError => e
49
+ { error: "Model not found: #{e.message}" }
50
+ rescue StandardError => e
51
+ { error: "#{e.class}: #{e.message}" }
52
+ end
53
+
54
+ private
55
+
56
+ def get_columns(klass)
57
+ klass.columns.map do |col|
58
+ {
59
+ name: col.name,
60
+ type: col.type,
61
+ null: col.null,
62
+ default: col.default
63
+ }
64
+ end
65
+ rescue StandardError
66
+ []
67
+ end
68
+
69
+ def get_associations(klass)
70
+ klass.reflect_on_all_associations.map do |assoc|
71
+ info = {
72
+ name: assoc.name,
73
+ type: assoc.macro,
74
+ class_name: assoc.class_name
75
+ }
76
+
77
+ # オプションがあれば追加
78
+ %i[dependent through source foreign_key].each do |opt|
79
+ value = assoc.options[opt]
80
+ info[opt] = value if value
81
+ end
82
+
83
+ info
84
+ end
85
+ rescue StandardError
86
+ []
87
+ end
88
+
89
+ def get_validations(klass)
90
+ klass.validators.map do |validator|
91
+ {
92
+ attributes: validator.attributes,
93
+ kind: validator.kind,
94
+ options: validator.options.reject { |k, _| %i[if unless].include?(k) }
95
+ }
96
+ end
97
+ rescue StandardError
98
+ []
99
+ end
100
+
101
+ def get_callbacks(klass)
102
+ callback_types = %i[
103
+ before_validation after_validation
104
+ before_save after_save around_save
105
+ before_create after_create around_create
106
+ before_update after_update around_update
107
+ before_destroy after_destroy around_destroy
108
+ ]
109
+
110
+ callback_types.to_h do |callback_type|
111
+ callbacks = begin
112
+ klass.send("_#{callback_type}_callbacks").map do |cb|
113
+ { filter: cb.filter.to_s, kind: cb.kind }
114
+ end
115
+ rescue StandardError
116
+ []
117
+ end
118
+ [callback_type, callbacks]
119
+ end.reject { |_, v| v.empty? }
120
+ end
121
+
122
+ def get_scopes(klass)
123
+ # Rails doesn't expose scope names directly, but we can check for scope methods
124
+ # that are defined on the class but not on ActiveRecord::Base
125
+ base_methods = ActiveRecord::Base.methods
126
+ scope_candidates = (klass.methods - base_methods).select do |method_name|
127
+ # スコープは通常、ActiveRecord::Relationを返す
128
+ begin
129
+ klass.respond_to?(method_name) &&
130
+ klass.method(method_name).arity <= 0
131
+ rescue StandardError
132
+ false
133
+ end
134
+ end
135
+ scope_candidates.sort.first(20)
136
+ rescue StandardError
137
+ []
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class ReadFile < Base
8
+ MAX_FILE_SIZE = 100_000 # 100KB
9
+ MAX_LINES = 500
10
+
11
+ class << self
12
+ def description
13
+ "Read source code from a file in the application. " \
14
+ "Can read models, controllers, views, configs, or any Ruby/text file. " \
15
+ "Optionally specify line range to read specific sections."
16
+ end
17
+
18
+ def parameters
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ path: {
23
+ type: "string",
24
+ description: "File path (relative to app root or absolute). Examples: 'app/models/user.rb', 'config/routes.rb'"
25
+ },
26
+ start_line: {
27
+ type: "integer",
28
+ description: "Start line number (1-indexed, optional)"
29
+ },
30
+ end_line: {
31
+ type: "integer",
32
+ description: "End line number (1-indexed, optional)"
33
+ }
34
+ },
35
+ required: ["path"]
36
+ }
37
+ end
38
+ end
39
+
40
+ def execute(binding, path:, start_line: nil, end_line: nil)
41
+ full_path = resolve_path(path)
42
+
43
+ unless File.exist?(full_path)
44
+ return { error: "File not found: #{path}", searched_path: full_path }
45
+ end
46
+
47
+ unless File.readable?(full_path)
48
+ return { error: "File not readable: #{path}" }
49
+ end
50
+
51
+ if File.size(full_path) > MAX_FILE_SIZE
52
+ return { error: "File too large (max #{MAX_FILE_SIZE / 1000}KB): #{path}" }
53
+ end
54
+
55
+ read_file_content(full_path, path, start_line, end_line)
56
+ rescue StandardError => e
57
+ { error: "#{e.class}: #{e.message}" }
58
+ end
59
+
60
+ private
61
+
62
+ def resolve_path(path)
63
+ return path if path.start_with?("/")
64
+
65
+ # Rails.root があればそこから
66
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
67
+ return File.join(Rails.root, path)
68
+ end
69
+
70
+ # Bundler.root があればそこから
71
+ if defined?(Bundler) && Bundler.respond_to?(:root) && Bundler.root
72
+ return File.join(Bundler.root, path)
73
+ end
74
+
75
+ # カレントディレクトリから
76
+ File.expand_path(path, Dir.pwd)
77
+ end
78
+
79
+ def read_file_content(full_path, original_path, start_line, end_line)
80
+ lines = File.readlines(full_path)
81
+ total_lines = lines.length
82
+
83
+ # 行範囲の指定がある場合
84
+ if start_line || end_line
85
+ start_idx = [(start_line || 1) - 1, 0].max
86
+ end_idx = [(end_line || total_lines) - 1, total_lines - 1].min
87
+ end_idx = [end_idx, start_idx + MAX_LINES - 1].min
88
+
89
+ selected_lines = lines[start_idx..end_idx]
90
+ content = selected_lines.map.with_index(start_idx + 1) { |line, num| "#{num}: #{line}" }.join
91
+
92
+ {
93
+ path: original_path,
94
+ full_path: full_path,
95
+ lines: "#{start_idx + 1}-#{end_idx + 1}",
96
+ total_lines: total_lines,
97
+ content: content
98
+ }
99
+ else
100
+ # 全体を読む(MAX_LINES制限あり)
101
+ if lines.length > MAX_LINES
102
+ content = lines.first(MAX_LINES).map.with_index(1) { |line, num| "#{num}: #{line}" }.join
103
+ {
104
+ path: original_path,
105
+ full_path: full_path,
106
+ lines: "1-#{MAX_LINES}",
107
+ total_lines: total_lines,
108
+ truncated: true,
109
+ content: content
110
+ }
111
+ else
112
+ content = lines.map.with_index(1) { |line, num| "#{num}: #{line}" }.join
113
+ {
114
+ path: original_path,
115
+ full_path: full_path,
116
+ total_lines: total_lines,
117
+ content: content
118
+ }
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end