noiseless 0.0.0 → 0.2.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.
- checksums.yaml +4 -4
- data/LICENSE.txt +28 -0
- data/README.md +214 -0
- data/lib/application_search.rb +15 -0
- data/lib/noiseless/adapter.rb +339 -0
- data/lib/noiseless/adapters/cluster_api.rb +18 -0
- data/lib/noiseless/adapters/elasticsearch.rb +30 -0
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +68 -0
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +83 -0
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +83 -0
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +209 -0
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +425 -0
- data/lib/noiseless/adapters/indices_api.rb +26 -0
- data/lib/noiseless/adapters/open_search.rb +168 -0
- data/lib/noiseless/adapters/postgresql.rb +171 -0
- data/lib/noiseless/adapters/typesense.rb +36 -0
- data/lib/noiseless/adapters.rb +14 -0
- data/lib/noiseless/ast/aggregation.rb +56 -0
- data/lib/noiseless/ast/bool.rb +16 -0
- data/lib/noiseless/ast/bulk.rb +18 -0
- data/lib/noiseless/ast/collapse.rb +16 -0
- data/lib/noiseless/ast/combined_fields.rb +33 -0
- data/lib/noiseless/ast/conversation.rb +29 -0
- data/lib/noiseless/ast/field_value_node.rb +16 -0
- data/lib/noiseless/ast/filter.rb +8 -0
- data/lib/noiseless/ast/hybrid.rb +35 -0
- data/lib/noiseless/ast/image_query.rb +29 -0
- data/lib/noiseless/ast/join.rb +31 -0
- data/lib/noiseless/ast/match.rb +8 -0
- data/lib/noiseless/ast/multi_match.rb +24 -0
- data/lib/noiseless/ast/paginate.rb +15 -0
- data/lib/noiseless/ast/prefix.rb +8 -0
- data/lib/noiseless/ast/range.rb +18 -0
- data/lib/noiseless/ast/root.rb +69 -0
- data/lib/noiseless/ast/search_after.rb +14 -0
- data/lib/noiseless/ast/sort.rb +15 -0
- data/lib/noiseless/ast/vector.rb +27 -0
- data/lib/noiseless/ast/wildcard.rb +8 -0
- data/lib/noiseless/ast.rb +30 -0
- data/lib/noiseless/bulk_importer.rb +195 -0
- data/lib/noiseless/callbacks.rb +138 -0
- data/lib/noiseless/connection_manager.rb +26 -0
- data/lib/noiseless/document_manager.rb +137 -0
- data/lib/noiseless/dsl.rb +107 -0
- data/lib/noiseless/generators/application_search_generator.rb +24 -0
- data/lib/noiseless/instrumentation.rb +174 -0
- data/lib/noiseless/introspection/console.rb +228 -0
- data/lib/noiseless/introspection/query_visualizer.rb +533 -0
- data/lib/noiseless/introspection.rb +221 -0
- data/lib/noiseless/mapping.rb +253 -0
- data/lib/noiseless/mapping_definition_processor.rb +231 -0
- data/lib/noiseless/model.rb +111 -0
- data/lib/noiseless/model_registry.rb +77 -0
- data/lib/noiseless/multi_search.rb +244 -0
- data/lib/noiseless/pagination.rb +375 -0
- data/lib/noiseless/query_builder.rb +284 -0
- data/lib/noiseless/railtie.rb +35 -0
- data/lib/noiseless/response/aggregations.rb +46 -0
- data/lib/noiseless/response/empty.rb +20 -0
- data/lib/noiseless/response/records.rb +94 -0
- data/lib/noiseless/response/results.rb +110 -0
- data/lib/noiseless/response/suggestions.rb +55 -0
- data/lib/noiseless/response.rb +98 -0
- data/lib/noiseless/response_factory.rb +32 -0
- data/lib/noiseless/runtime_reset_middleware.rb +15 -0
- data/lib/noiseless/search_index_update_job.rb +84 -0
- data/lib/noiseless/test_case.rb +230 -0
- data/lib/noiseless/test_helper.rb +295 -0
- data/lib/noiseless/version.rb +2 -2
- data/lib/noiseless.rb +146 -2
- data/lib/tasks/benchmark.rake +35 -0
- data/lib/tasks/release.rake +22 -0
- data/lib/tasks/test.rake +11 -0
- metadata +265 -14
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
# Instrumentation via ActiveSupport
|
|
5
|
+
module Instrumentation
|
|
6
|
+
def instrument(event, payload = {})
|
|
7
|
+
start_time = Time.current
|
|
8
|
+
payload = payload.merge(
|
|
9
|
+
adapter: self.class.name,
|
|
10
|
+
connection: connection_info,
|
|
11
|
+
start_time: start_time
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
result = ActiveSupport::Notifications.instrument("noiseless.#{event}", payload) do
|
|
15
|
+
yield if block_given?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Update runtime tracking for Rails
|
|
19
|
+
add_to_runtime(Time.current - start_time) if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
20
|
+
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def connection_info
|
|
27
|
+
{
|
|
28
|
+
hosts: @hosts&.take(3), # Limit to first 3 hosts for brevity
|
|
29
|
+
adapter_class: self.class.name
|
|
30
|
+
}
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{ adapter_class: self.class.name }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_to_runtime(duration)
|
|
36
|
+
Thread.current[:noiseless_runtime] ||= 0
|
|
37
|
+
Thread.current[:noiseless_runtime] += duration * 1000 # Convert to milliseconds
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Log subscriber for Rails integration
|
|
42
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
43
|
+
def search(event)
|
|
44
|
+
return unless logger.debug?
|
|
45
|
+
|
|
46
|
+
indexes = event.payload[:indexes]&.join(", ") || "unknown"
|
|
47
|
+
duration = event.duration.round(2)
|
|
48
|
+
|
|
49
|
+
debug "Noiseless Search (#{duration}ms) indexes=[#{indexes}] #{query_summary(event.payload[:query])}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def bulk(event)
|
|
53
|
+
return unless logger.debug?
|
|
54
|
+
|
|
55
|
+
actions_count = event.payload[:actions_count] || 0
|
|
56
|
+
duration = event.duration.round(2)
|
|
57
|
+
|
|
58
|
+
debug "Noiseless Bulk (#{duration}ms) actions=#{actions_count}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def index_document(event)
|
|
62
|
+
return unless logger.debug?
|
|
63
|
+
|
|
64
|
+
index = event.payload[:index]
|
|
65
|
+
id = event.payload[:id]
|
|
66
|
+
duration = event.duration.round(2)
|
|
67
|
+
|
|
68
|
+
debug "Noiseless Index Document (#{duration}ms) index=#{index} id=#{id}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def update_document(event)
|
|
72
|
+
return unless logger.debug?
|
|
73
|
+
|
|
74
|
+
index = event.payload[:index]
|
|
75
|
+
id = event.payload[:id]
|
|
76
|
+
changes_count = event.payload[:changes_count] || 0
|
|
77
|
+
duration = event.duration.round(2)
|
|
78
|
+
|
|
79
|
+
debug "Noiseless Update Document (#{duration}ms) index=#{index} id=#{id} changes=#{changes_count}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def delete_document(event)
|
|
83
|
+
return unless logger.debug?
|
|
84
|
+
|
|
85
|
+
index = event.payload[:index]
|
|
86
|
+
id = event.payload[:id]
|
|
87
|
+
duration = event.duration.round(2)
|
|
88
|
+
|
|
89
|
+
debug "Noiseless Delete Document (#{duration}ms) index=#{index} id=#{id}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create_index(event)
|
|
93
|
+
return unless logger.debug?
|
|
94
|
+
|
|
95
|
+
index = event.payload[:index]
|
|
96
|
+
duration = event.duration.round(2)
|
|
97
|
+
|
|
98
|
+
debug "Noiseless Create Index (#{duration}ms) index=#{index}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def delete_index(event)
|
|
102
|
+
return unless logger.debug?
|
|
103
|
+
|
|
104
|
+
index = event.payload[:index]
|
|
105
|
+
duration = event.duration.round(2)
|
|
106
|
+
|
|
107
|
+
debug "Noiseless Delete Index (#{duration}ms) index=#{index}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def query_summary(query)
|
|
113
|
+
return "empty" unless query.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
parts = []
|
|
116
|
+
|
|
117
|
+
if query[:query]&.dig(:bool, :must)&.any?
|
|
118
|
+
must_count = query[:query][:bool][:must].size
|
|
119
|
+
parts << "must:#{must_count}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if query[:query]&.dig(:bool, :filter)&.any?
|
|
123
|
+
filter_count = query[:query][:bool][:filter].size
|
|
124
|
+
parts << "filter:#{filter_count}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if query[:sort]&.any?
|
|
128
|
+
sort_count = query[:sort].size
|
|
129
|
+
parts << "sort:#{sort_count}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if query[:from] || query[:size]
|
|
133
|
+
parts << "from:#{query[:from] || 0}"
|
|
134
|
+
parts << "size:#{query[:size] || 20}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parts.join(" ")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Runtime tracking for Rails
|
|
142
|
+
module ControllerRuntime
|
|
143
|
+
extend ActiveSupport::Concern
|
|
144
|
+
|
|
145
|
+
protected
|
|
146
|
+
|
|
147
|
+
def append_info_to_payload(payload)
|
|
148
|
+
super
|
|
149
|
+
payload[:noiseless_runtime] = noiseless_runtime
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def cleanup_view_runtime
|
|
153
|
+
runtime_before_render = noiseless_runtime
|
|
154
|
+
runtime = super
|
|
155
|
+
runtime_after_render = noiseless_runtime
|
|
156
|
+
runtime + runtime_after_render - runtime_before_render
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def noiseless_runtime
|
|
162
|
+
Thread.current[:noiseless_runtime] ||= 0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
module ClassMethods
|
|
166
|
+
def log_process_action(payload)
|
|
167
|
+
messages = super
|
|
168
|
+
runtime = payload[:noiseless_runtime]
|
|
169
|
+
messages << ("Noiseless: %.1fms" % runtime) if runtime&.positive?
|
|
170
|
+
messages
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module Introspection
|
|
5
|
+
class Console
|
|
6
|
+
def self.inspect_adapter(adapter)
|
|
7
|
+
puts "[INSPECT] Adapter Introspection"
|
|
8
|
+
puts "=" * 50
|
|
9
|
+
|
|
10
|
+
info = adapter.adapter_info
|
|
11
|
+
|
|
12
|
+
puts "Adapter Type: #{info[:adapter_type]}"
|
|
13
|
+
puts "Execution Mode: #{info[:execution_mode]}"
|
|
14
|
+
puts "Engine Name: #{info[:engine_name]}"
|
|
15
|
+
puts "Capabilities: #{info[:capabilities].join(', ')}"
|
|
16
|
+
|
|
17
|
+
puts "\nExecution Modules:"
|
|
18
|
+
info[:execution_module].each do |mod|
|
|
19
|
+
puts " - #{mod[:name]}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
puts "\n#{'=' * 50}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.compare_query_across_engines(ast_node, **)
|
|
26
|
+
puts "[COMPARE] Cross-Engine Query Comparison"
|
|
27
|
+
puts "=" * 60
|
|
28
|
+
|
|
29
|
+
comparison = QueryVisualizer.compare_across_engines(ast_node, **)
|
|
30
|
+
|
|
31
|
+
puts "Original AST:"
|
|
32
|
+
puts JSON.pretty_generate(comparison[:original_ast])
|
|
33
|
+
|
|
34
|
+
puts "\nEngine Translations:"
|
|
35
|
+
comparison[:engine_translations].each do |engine, data|
|
|
36
|
+
puts "\n#{engine.to_s.humanize}:"
|
|
37
|
+
if data[:error]
|
|
38
|
+
puts " [ERROR] #{data[:error]}"
|
|
39
|
+
else
|
|
40
|
+
puts " [OK] Available"
|
|
41
|
+
puts " Performance Score: #{data[:estimated_performance][:estimated_score]}"
|
|
42
|
+
puts " Query Differences: #{data[:query_differences].size} found"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts "\nCompatibility Analysis:"
|
|
47
|
+
analysis = comparison[:compatibility_analysis]
|
|
48
|
+
puts " Compatible Engines: #{analysis[:compatible_engines].join(', ')}"
|
|
49
|
+
puts " Common Features: #{analysis[:common_features]&.join(', ') || 'None'}"
|
|
50
|
+
|
|
51
|
+
if analysis[:potential_issues].any?
|
|
52
|
+
puts " [WARN] Potential Issues:"
|
|
53
|
+
analysis[:potential_issues].each do |issue|
|
|
54
|
+
puts " - #{issue[:description]} (#{issue[:severity]})"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
puts "\nRecommendations:"
|
|
59
|
+
comparison[:recommendations].each do |rec|
|
|
60
|
+
puts " [TIP] #{rec[:recommendation]}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
puts "\n#{'=' * 60}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.visualize_ast(ast_node, format: :tree)
|
|
67
|
+
puts "[AST] Visualization (#{format})"
|
|
68
|
+
puts "=" * 40
|
|
69
|
+
|
|
70
|
+
visualization = QueryVisualizer.visualize_ast(ast_node, format: format)
|
|
71
|
+
puts visualization
|
|
72
|
+
|
|
73
|
+
puts "=" * 40
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.explain_query_execution(ast_node, adapter)
|
|
77
|
+
puts "[EXPLAIN] Query Execution Explanation"
|
|
78
|
+
puts "=" * 50
|
|
79
|
+
|
|
80
|
+
flow_info = QueryVisualizer.explain_query_flow(ast_node, adapter)
|
|
81
|
+
explanation = flow_info[:explanation]
|
|
82
|
+
|
|
83
|
+
puts "Adapter: #{explanation[:adapter][:adapter_type]} (#{explanation[:adapter][:execution_mode]})"
|
|
84
|
+
puts "Engine: #{explanation[:adapter][:engine_name]}"
|
|
85
|
+
|
|
86
|
+
puts "\nExecution Plan:"
|
|
87
|
+
explanation[:execution_plan].each_with_index do |step, index|
|
|
88
|
+
puts " #{index + 1}. #{step[:description]}"
|
|
89
|
+
puts " Cost: #{step[:estimated_cost]}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "\nPerformance Breakdown:"
|
|
93
|
+
flow_info[:performance_breakdown].each do |metric|
|
|
94
|
+
puts " #{metric[:metric]}: #{metric[:value]} #{metric[:unit]}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if flow_info[:optimization_suggestions].any?
|
|
98
|
+
puts "\nOptimization Suggestions:"
|
|
99
|
+
flow_info[:optimization_suggestions].each do |suggestion|
|
|
100
|
+
puts " [TIP] #{suggestion[:suggestion]} (Impact: #{suggestion[:impact]})"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "\n#{'=' * 50}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.profile_query_performance(ast_node, adapter, iterations: 10)
|
|
108
|
+
puts "[PROFILE] Query Performance Profile"
|
|
109
|
+
puts "=" * 50
|
|
110
|
+
|
|
111
|
+
puts "Running #{iterations} iterations on #{adapter.adapter_info[:adapter_type]} (#{adapter.adapter_info[:execution_mode]})..."
|
|
112
|
+
|
|
113
|
+
profile = adapter.profile_query(ast_node, iterations: iterations)
|
|
114
|
+
summary = profile[:summary]
|
|
115
|
+
|
|
116
|
+
puts "\nPerformance Summary:"
|
|
117
|
+
puts " Minimum: #{summary[:min_ms]}ms"
|
|
118
|
+
puts " Maximum: #{summary[:max_ms]}ms"
|
|
119
|
+
puts " Average: #{summary[:avg_ms]}ms"
|
|
120
|
+
puts " Median: #{summary[:median_ms]}ms"
|
|
121
|
+
puts " Std Dev: #{summary[:std_dev_ms]}ms"
|
|
122
|
+
|
|
123
|
+
# Show distribution
|
|
124
|
+
puts "\nTime Distribution:"
|
|
125
|
+
bins = create_histogram_bins(profile[:measurements].pluck(:total_time_ms))
|
|
126
|
+
bins.each do |bin|
|
|
127
|
+
bar = "█" * (bin[:count] * 50 / iterations)
|
|
128
|
+
puts " #{bin[:range]}: #{bar} (#{bin[:count]})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
puts "\n#{'=' * 50}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.compatibility_matrix
|
|
135
|
+
puts "[MATRIX] Adapter Compatibility Matrix"
|
|
136
|
+
puts "=" * 60
|
|
137
|
+
|
|
138
|
+
matrix = Noiseless::Adapter.new.compatibility_matrix
|
|
139
|
+
|
|
140
|
+
matrix.each do |adapter_key, info|
|
|
141
|
+
status = info[:available] ? "[OK]" : "[X]"
|
|
142
|
+
name = adapter_key.to_s.humanize
|
|
143
|
+
|
|
144
|
+
puts "#{status} #{name}"
|
|
145
|
+
if info[:available]
|
|
146
|
+
puts " Engine: #{info[:engine_name]}"
|
|
147
|
+
puts " Capabilities: #{info[:capabilities].join(', ')}"
|
|
148
|
+
else
|
|
149
|
+
puts " Error: #{info[:error]}"
|
|
150
|
+
end
|
|
151
|
+
puts
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
puts "=" * 60
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.interactive_mode
|
|
158
|
+
puts "[INTERACTIVE] Noiseless Interactive Introspection Mode"
|
|
159
|
+
puts "Type 'help' for available commands or 'exit' to quit"
|
|
160
|
+
puts "=" * 60
|
|
161
|
+
|
|
162
|
+
loop do
|
|
163
|
+
print "noiseless> "
|
|
164
|
+
input = gets.chomp.strip
|
|
165
|
+
|
|
166
|
+
case input
|
|
167
|
+
when "help"
|
|
168
|
+
show_help
|
|
169
|
+
when "adapters"
|
|
170
|
+
compatibility_matrix
|
|
171
|
+
when "exit", "quit"
|
|
172
|
+
puts "Goodbye!"
|
|
173
|
+
break
|
|
174
|
+
when /^profile\s+(.+)/
|
|
175
|
+
# TODO: Parse query and run profile
|
|
176
|
+
puts "Profile command not yet implemented"
|
|
177
|
+
when /^compare\s+(.+)/
|
|
178
|
+
# TODO: Parse query and run comparison
|
|
179
|
+
puts "Compare command not yet implemented"
|
|
180
|
+
else
|
|
181
|
+
puts "Unknown command: #{input}. Type 'help' for available commands."
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.create_histogram_bins(values, bin_count: 5)
|
|
187
|
+
return [] if values.empty?
|
|
188
|
+
|
|
189
|
+
min_val = values.min
|
|
190
|
+
max_val = values.max
|
|
191
|
+
bin_size = (max_val - min_val) / bin_count.to_f
|
|
192
|
+
|
|
193
|
+
bins = Array.new(bin_count) do |i|
|
|
194
|
+
range_start = min_val + (i * bin_size)
|
|
195
|
+
range_end = min_val + ((i + 1) * bin_size)
|
|
196
|
+
{
|
|
197
|
+
range: "#{range_start.round(2)}-#{range_end.round(2)}ms",
|
|
198
|
+
count: 0
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
values.each do |value|
|
|
203
|
+
bin_index = ((value - min_val) / bin_size).floor
|
|
204
|
+
bin_index = [bin_index, bin_count - 1].min # Ensure we don't exceed bounds
|
|
205
|
+
bins[bin_index][:count] += 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
bins
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.show_help
|
|
212
|
+
puts <<~HELP
|
|
213
|
+
Available Commands:
|
|
214
|
+
|
|
215
|
+
help - Show this help message
|
|
216
|
+
adapters - Show adapter compatibility matrix
|
|
217
|
+
exit/quit - Exit interactive mode
|
|
218
|
+
|
|
219
|
+
Coming Soon:
|
|
220
|
+
profile <query> - Profile query performance
|
|
221
|
+
compare <query> - Compare query across engines
|
|
222
|
+
explain <query> - Explain query execution
|
|
223
|
+
visualize <query> - Visualize AST structure
|
|
224
|
+
HELP
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|