ffast 0.2.3 → 0.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da30bc7abc983e2ac54c1c4d1f454878d75dce0dd285a0e245d6ed54d73e4040
4
- data.tar.gz: f286ce93d5ce2970d1c3a528d423bf8b8ed9239140f798f895a67e0aa1b8e2d2
3
+ metadata.gz: 4b5b5e27292bfe589236ccf40952b5da73f4dfc4b35df85220d9beebae7c725e
4
+ data.tar.gz: 6c4466b554e164c447cfb02760c5b6cd262c0f904f7ea8c35897288efa4668ec
5
5
  SHA512:
6
- metadata.gz: 83c218849cd4a9184b34f9d760d537cae650fe8a96428aa83fdeff12ed6afc57129fff3ce4a0c6d6924225e178f80c114afb021e19ca41bc2677c85b0668ee99
7
- data.tar.gz: 6d4499e8a11398c3f9bc8a04adcd5efd65ef1b385c9bc37370edc31f9375b8d844e8ed61981e33831b2b83961edd4245059a38167c2f7a5ffbc52dd49acb9fff
6
+ metadata.gz: 0f8ccd9bcd621b69847fd1092b0fa08cada32258a2ec1f6b04654744c8930718628955698673577e4431a6fe6146da744bcb6aafdada442aa08bfe8e5ba32780
7
+ data.tar.gz: 263b032df9ed1ea5b8d7cd71e1bd613dc781c53fc1dd10cb8bedbe526d904543e1331bb43f345679954e7ebc404b0bdfb5f965dffd35b74c454038ba7ae0fb31
@@ -0,0 +1,71 @@
1
+ # Skill: Fast Pattern Expert
2
+
3
+ You are an expert in constructing and translating natural language queries into `Fast` AST patterns for Ruby.
4
+
5
+ ## Core Expertise
6
+ - Translating structural descriptions of Ruby code into S-expression based AST patterns.
7
+ - Understanding the Ruby AST (Prism/Parser based).
8
+ - Navigating and searching codebases semantically using `Fast`.
9
+
10
+ ## Syntax Reference
11
+ - `(node_type ...)` : Search for a node of a specific type.
12
+ - `_` : Matches any non-nil node/value.
13
+ - `nil` : Matches exactly `nil`.
14
+ - `...` : When last in a list, matches zero or more remaining children. Elsewhere, matches a node with children.
15
+ - `^` : Navigate to the parent node.
16
+ - `$` : Capture the matched node or value.
17
+ - `{type1 type2}` : Union (OR) - matches if any internal expression matches.
18
+ - `[expr1 expr2]` : Intersection (AND) - matches only if all internal expressions match.
19
+ - `!expr` : Negation (NOT) - matches if the expression does not match.
20
+ - `?expr` : Maybe - matches if the node is nil or matches the expression.
21
+ - `\1`, `\2` : Backreference to previous captures.
22
+ - `#custom_method` : Call a custom Ruby method for validation.
23
+ - `.instance_method?` : Call an instance method on the node for validation (e.g., `.odd?`).
24
+
25
+ ## Common Ruby AST Nodes
26
+ - `(def name (args) body)` : Method definition.
27
+ - `(defs receiver name (args) body)` : Singleton method definition.
28
+ - `(send receiver method_name args...)` : Method call.
29
+ - `(class name superclass body)` : Class definition.
30
+ - `(module name body)` : Module definition.
31
+ - `(const scope name)` : Constant reference (scope is nil for top-level).
32
+ - `(casgn scope name value)` : Constant assignment.
33
+ - `(lvar name)` : Local variable read.
34
+ - `(lvasgn name value)` : Local variable assignment.
35
+ - `(ivar name)` : Instance variable read.
36
+ - `(ivasgn name value)` : Instance variable assignment.
37
+ - `(hash (pair key value)...)` : Hash literal.
38
+ - `(array elements...)` : Array literal.
39
+
40
+ ## Translation Examples
41
+
42
+ ### Methods
43
+ - "Find all methods named 'process'" -> `(def process)`
44
+ - "Find methods with at least 3 arguments" -> `(def _ (args _ _ _ ...))`
45
+ - "Find singleton methods (self.method)" -> `(defs ...)`
46
+ - "Find methods that call 'super'" -> `(def _ _ (send nil :super ...))`
47
+
48
+ ### Classes & Modules
49
+ - "Find classes inheriting from ApplicationController" -> `(class _ (const nil ApplicationController))`
50
+ - "Find classes defined inside the 'User' namespace" -> `(class (const (const nil User) _) ...)`
51
+ - "Find modules that include 'Enumerable'" -> `(module _ (begin < (send nil include (const nil Enumerable)) ...))`
52
+
53
+ ### Method Calls
54
+ - "Find all calls to 'User.find'" -> `(send (const nil User) find ...)`
55
+ - "Find calls to 'where' with a hash argument" -> `(send _ where (hash ...))`
56
+ - "Find calls to 'exit' or 'abort'" -> `(send nil {exit abort} ...)`
57
+
58
+ ### Variables & Constants
59
+ - "Find where the 'DEBUG' constant is assigned" -> `(casgn nil DEBUG)`
60
+ - "Find all uses of instance variable '@user'" -> `(ivar @user)`
61
+ - "Find assignments to '@user'" -> `(ivasgn @user)`
62
+
63
+ ## Strategy for Complex Queries
64
+ 1. **Identify the anchor node**: What is the primary structure? (e.g., a method definition, a specific call).
65
+ 2. **Describe children**: What must be true about its arguments or body?
66
+ 3. **Use Union/Intersection**: Combine multiple constraints using `{}` or `[]`.
67
+ 4. **Capture if needed**: Use `$` if you only want a specific part of the match.
68
+ 5. **Validate**: Always use `validate_fast_pattern` if available to check syntax.
69
+
70
+ ## AST Triage
71
+ If you are unsure of the AST structure for a piece of code, use `Fast.ast("your code snippet")` or `Fast.ast_from_file` to see the s-expression representation. This is the most reliable way to build a pattern.
data/Fastfile CHANGED
@@ -15,7 +15,7 @@ version_file = Dir['lib/*/version.rb'].first
15
15
  Fast.shortcut(:version, '(casgn nil VERSION (str _))', version_file)
16
16
 
17
17
  # Show all classes that inherits Fast::Find
18
- Fast.shortcut(:finders, '(class ... (const nil Find)', 'lib')
18
+ Fast.shortcut(:finders, '(class ... (const nil Find))', 'lib')
19
19
 
20
20
  # You can run shortcuts appending a dot to the shortcut.
21
21
  # $ fast .version
@@ -23,13 +23,13 @@ Fast.shortcut(:finders, '(class ... (const nil Find)', 'lib')
23
23
  # VERSION = '0.1.2'
24
24
 
25
25
  # Simple shortcut that I used often to show how the expression parser works
26
- Fast.shortcut(:parser, '(class (const nil ExpressionParser)', 'lib/fast.rb')
27
- Fast.shortcut(:sql_parser, '(def parse', 'lib/fast/sql.rb')
26
+ Fast.shortcut(:parser, '(class (const nil ExpressionParser))', 'lib/fast.rb')
27
+ Fast.shortcut(:sql_parser, '(def parse ...)', 'lib/fast/sql.rb')
28
28
 
29
29
  # Use `fast .bump_version` to rewrite the version file
30
30
  Fast.shortcut :bump_version do
31
31
  new_version = nil
32
- rewrite_file('(casgn nil VERSION (str _)', version_file) do |node|
32
+ rewrite_file('(casgn nil VERSION (str _))', version_file) do |node|
33
33
  target = node.children.last.loc.expression
34
34
  pieces = target.source.split('.').map(&:to_i)
35
35
  pieces.reverse.each_with_index do |fragment, i|
data/README.md CHANGED
@@ -12,20 +12,6 @@ the code was written without an AST.
12
12
 
13
13
  Check out the official documentation: https://jonatas.github.io/fast.
14
14
 
15
- ## Documentation locally
16
-
17
- The documentation site is built with MkDocs Material and a few Markdown
18
- extensions. To run it locally:
19
-
20
- ```bash
21
- python3 -m venv .venv
22
- source .venv/bin/activate
23
- python3 -m pip install -r requirements-docs.txt
24
- mkdocs serve
25
- ```
26
-
27
- Then open `http://127.0.0.1:8000`.
28
-
29
15
  ## Token Syntax for `find` in AST
30
16
 
31
17
  The current version of Fast covers the following token elements:
@@ -694,6 +680,20 @@ code("a = 1") # => s(:lvasgn, s(:int, 1))
694
680
 
695
681
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
696
682
 
683
+ ## Documentation locally
684
+
685
+ The documentation site is built with MkDocs Material and a few Markdown
686
+ extensions. To run it locally:
687
+
688
+ ```bash
689
+ python3 -m venv .venv
690
+ source .venv/bin/activate
691
+ python3 -m pip install -r requirements-docs.txt
692
+ mkdocs serve
693
+ ```
694
+
695
+ Then open `http://127.0.0.1:8000`.
696
+
697
697
  ## Contributing
698
698
 
699
699
  Bug reports and pull requests are welcome on GitHub at https://github.com/jonatas/fast. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
@@ -0,0 +1,36 @@
1
+ # Empowering LLM Agents with Natural Language to Fast Patterns
2
+
3
+ Today I'm excited to announce a significant set of improvements to `fast`, specifically designed to bridge the gap between human reasoning (and LLM agents) and the technical precision of Ruby AST searching.
4
+
5
+ ## The Problem: AST Patterns are Hard
6
+
7
+ Searching through code using AST patterns is incredibly powerful, but constructing the right S-expression can be daunting. Whether you're a human or an AI agent, getting the syntax just right—especially for complex queries—often involves a lot of trial and error.
8
+
9
+ ## The Solution: NL-to-Fast Translation
10
+
11
+ We've introduced three new tools to solve this:
12
+
13
+ 1. **The `fast-pattern-expert` Skill**: A specialized Gemini CLI skill that provides deep guidance, syntax references, and few-shot examples for translating structural descriptions of Ruby code into valid `Fast` patterns.
14
+ 2. **`validate_fast_pattern` MCP Tool**: A new tool for our Model Context Protocol (MCP) server that allows agents to verify their generated patterns before executing them.
15
+ 3. **`--validate-pattern` CLI Flag**: A quick way for humans to check if their pattern syntax is correct, checking for balanced nesting and valid token types.
16
+
17
+ ### Example in Action:
18
+ **Query:** "Find all classes that inherit from `BaseService` and have a `call` method."
19
+ **Pattern:** `[(class _ (const nil BaseService)) (def call)]`
20
+
21
+ ## Robustness and Precision
22
+
23
+ We've also beefed up the core of `fast`:
24
+ - **Stricter Validation**: The `ExpressionParser` now tracks nesting levels and unconsumed tokens, providing helpful error messages instead of silently failing or returning partial matches.
25
+ - **Improved `...` Matcher**: The `...` literal now acts as a "rest-of-children" matcher when used at the end of a pattern, making it much easier to match methods with any number of arguments.
26
+ - **Graceful Degradation**: Multi-file scans (`.scan`) now degrade gracefully if a single file fails to parse, ensuring your reconnaissance isn't aborted by one unsupported syntax node.
27
+
28
+ ## A Safer Release Workflow
29
+
30
+ On the operational side, I'm moving our gem release process to **GitHub Actions**. By releasing from a clean CI sandbox instead of my local machine, we ensure a much higher level of security and reproducibility. This move minimizes the risk of local environment contamination and provides a transparent, auditable path from source to RubyGems.
31
+
32
+ ## Release 0.2.4
33
+
34
+ To celebrate these features, we are releasing version `0.2.4` today!
35
+
36
+ Happy searching!
data/lib/fast/cli.rb CHANGED
@@ -12,6 +12,12 @@ require 'ostruct'
12
12
  # It defines #report and #highlight functions that can be used to pretty print
13
13
  # code and results from the search.
14
14
  module Fast
15
+ module SymbolExtension
16
+ def loc
17
+ OpenStruct.new(expression: OpenStruct.new(line: 0))
18
+ end
19
+ end
20
+
15
21
  module_function
16
22
 
17
23
  # Highligh some source code based on the node.
@@ -20,19 +26,19 @@ module Fast
20
26
  # @param colorize [Boolean] skips `CodeRay` processing when false.
21
27
  # @param level [Integer] defines the max depth to print the AST.
22
28
  def highlight(node, show_sexp: false, colorize: true, sql: false, level: nil)
23
- output =
24
- if node.respond_to?(:loc) && !show_sexp
25
- if level
26
- Fast.fold_source(node, level: level)
27
- else
28
- wrap_source_range(node).source
29
- end
30
- elsif show_sexp && level && Fast.ast_node?(node)
31
- Fast.fold_ast(node, level: level).to_s
32
- elsif show_sexp
33
- node.to_s
29
+ output =
30
+ if node.respond_to?(:loc) && !show_sexp
31
+ if level
32
+ Fast.fold_source(node, level: level)
34
33
  else
35
- node
34
+ wrap_source_range(node).source
35
+ end
36
+ elsif show_sexp && level && Fast.ast_node?(node)
37
+ Fast.fold_ast(node, level: level).to_s
38
+ elsif show_sexp
39
+ node.to_s
40
+ else
41
+ node.to_s
36
42
  end
37
43
  return output unless colorize
38
44
 
@@ -78,6 +84,9 @@ module Fast
78
84
  # Fast.report(result, file: 'file.rb')
79
85
  def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true, level: nil) # rubocop:disable Metrics/ParameterLists
80
86
  if file
87
+ if result.is_a?(Symbol) && !result.respond_to?(:loc)
88
+ result.extend(SymbolExtension)
89
+ end
81
90
  line = result.loc.expression.line if Fast.ast_node?(result) && result.respond_to?(:loc)
82
91
  if show_link
83
92
  puts(result.link)
@@ -166,6 +175,16 @@ module Fast
166
175
  @from_code = true
167
176
  end
168
177
 
178
+ opts.on('--validate-pattern PATTERN', 'Validate a node pattern') do |pattern|
179
+ begin
180
+ Fast.expression(pattern)
181
+ puts "Pattern is valid."
182
+ rescue StandardError => e
183
+ puts "Invalid pattern: #{e.message}"
184
+ end
185
+ exit
186
+ end
187
+
169
188
  opts.on_tail('--version', 'Show version') do
170
189
  puts Fast::VERSION
171
190
  exit
@@ -10,6 +10,17 @@ module Fast
10
10
  # Implements the Model Context Protocol (MCP) server over STDIO.
11
11
  class McpServer
12
12
  TOOLS = [
13
+ {
14
+ name: 'validate_fast_pattern',
15
+ description: 'Validate a Fast AST pattern. Returns true if valid, or a specific syntax error message if invalid.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ pattern: { type: 'string', description: 'Fast AST pattern to validate.' }
20
+ },
21
+ required: ['pattern']
22
+ }
23
+ },
13
24
  {
14
25
  name: 'search_ruby_ast',
15
26
  description: 'Search Ruby files using a Fast AST pattern. Returns file, line range, and source. Use show_ast=true only when you need the s-expression.',
@@ -146,8 +157,14 @@ module Fast
146
157
  args = params['arguments'] || {}
147
158
  show_ast = args['show_ast'] || false
148
159
 
160
+ if args['pattern'] && !args['pattern'].start_with?('(', '{', '[') && !args['pattern'].match?(/^[a-z_]+$/)
161
+ raise "Invalid Fast AST pattern: '#{args['pattern']}'. Did you mean to use an s-expression like '(#{args['pattern']})'?"
162
+ end
163
+
149
164
  result =
150
165
  case tool_name
166
+ when 'validate_fast_pattern'
167
+ execute_validate_pattern(args['pattern'])
151
168
  when 'search_ruby_ast'
152
169
  execute_search(args['pattern'], args['paths'], show_ast: show_ast)
153
170
  when 'ruby_method_source'
@@ -170,6 +187,13 @@ module Fast
170
187
  write_error(id, -32603, 'Tool execution failed', e.message)
171
188
  end
172
189
 
190
+ def execute_validate_pattern(pattern)
191
+ Fast.expression(pattern)
192
+ { valid: true }
193
+ rescue StandardError => e
194
+ { valid: false, error: e.message }
195
+ end
196
+
173
197
  def execute_search(pattern, paths, show_ast: false)
174
198
  results = []
175
199
  on_result = ->(file, matches) do
@@ -135,8 +135,12 @@ module Fast
135
135
  build_node(:sym, [node.unescaped], node, source, buffer_name)
136
136
  when Prism::StringNode
137
137
  build_node(:str, [node.unescaped], node, source, buffer_name)
138
+ when Prism::XStringNode
139
+ build_node(:xstr, [node.unescaped], node, source, buffer_name)
138
140
  when Prism::InterpolatedStringNode
139
141
  build_node(:dstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name)
142
+ when Prism::InterpolatedXStringNode
143
+ build_node(:dxstr, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name)
140
144
  when Prism::InterpolatedSymbolNode
141
145
  build_node(:dsym, node.parts.filter_map { |part| adapt(part, source, buffer_name) }, node, source, buffer_name)
142
146
  when Prism::ArrayNode
@@ -183,6 +187,8 @@ module Fast
183
187
  build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.statements, source, buffer_name), adapt(node.consequent, source, buffer_name)], node, source, buffer_name)
184
188
  when Prism::UnlessNode
185
189
  build_node(:if, [adapt(node.predicate, source, buffer_name), adapt(node.consequent, source, buffer_name), adapt(node.statements, source, buffer_name)], node, source, buffer_name)
190
+ when Prism::RescueModifierNode
191
+ build_node(:rescue, [adapt(node.expression, source, buffer_name), build_node(:resbody, [nil, nil, adapt(node.rescue_expression, source, buffer_name)], node, source, buffer_name), nil], node, source, buffer_name)
186
192
  when Prism::CaseNode
187
193
  children = [adapt(node.predicate, source, buffer_name)]
188
194
  children.concat(node.conditions.map { |condition| adapt(condition, source, buffer_name) })
@@ -231,10 +237,10 @@ module Fast
231
237
  return build_node(:args, [], nil, source, buffer_name) unless node
232
238
 
233
239
  children = []
234
- children.concat(node.requireds.map { |child| build_node(:arg, [child.name], child, source, buffer_name) }) if node.respond_to?(:requireds)
235
- children.concat(node.optionals.map { |child| build_node(:optarg, [child.name, adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals)
240
+ children.concat(node.requireds.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:requireds)
241
+ children.concat(node.optionals.map { |child| build_node(:optarg, [parameter_name(child), adapt(child.value, source, buffer_name)], child, source, buffer_name) }) if node.respond_to?(:optionals)
236
242
  children << build_node(:restarg, [parameter_name(node.rest)], node.rest, source, buffer_name) if node.respond_to?(:rest) && node.rest
237
- children.concat(node.posts.map { |child| build_node(:arg, [child.name], child, source, buffer_name) }) if node.respond_to?(:posts)
243
+ children.concat(node.posts.map { |child| adapt_required_parameter(child, source, buffer_name) }) if node.respond_to?(:posts)
238
244
  children.concat(node.keywords.map { |child| adapt_keyword_parameter(child, source, buffer_name) }) if node.respond_to?(:keywords)
239
245
  children << build_node(:kwrestarg, [parameter_name(node.keyword_rest)], node.keyword_rest, source, buffer_name) if node.respond_to?(:keyword_rest) && node.keyword_rest
240
246
  children << build_node(:blockarg, [parameter_name(node.block)], node.block, source, buffer_name) if node.respond_to?(:block) && node.block
@@ -248,14 +254,25 @@ module Fast
248
254
  adapt_parameters(params, source, buffer_name)
249
255
  end
250
256
 
257
+ def adapt_required_parameter(child, source, buffer_name)
258
+ if child.is_a?(Prism::MultiTargetNode)
259
+ mlhs_children = child.lefts.map { |c| adapt_required_parameter(c, source, buffer_name) }
260
+ mlhs_children << build_node(:restarg, [parameter_name(child.rest)], child.rest, source, buffer_name) if child.respond_to?(:rest) && child.rest
261
+ mlhs_children.concat(child.rights.map { |c| adapt_required_parameter(c, source, buffer_name) }) if child.respond_to?(:rights)
262
+ build_node(:mlhs, mlhs_children, child, source, buffer_name)
263
+ else
264
+ build_node(:arg, [parameter_name(child)], child, source, buffer_name)
265
+ end
266
+ end
267
+
251
268
  def adapt_keyword_parameter(node, source, buffer_name)
252
269
  case node
253
270
  when Prism::RequiredKeywordParameterNode
254
- build_node(:kwarg, [node.name], node, source, buffer_name)
271
+ build_node(:kwarg, [parameter_name(node)], node, source, buffer_name)
255
272
  when Prism::OptionalKeywordParameterNode
256
- build_node(:kwoptarg, [node.name, adapt(node.value, source, buffer_name)], node, source, buffer_name)
273
+ build_node(:kwoptarg, [parameter_name(node), adapt(node.value, source, buffer_name)], node, source, buffer_name)
257
274
  else
258
- build_node(:arg, [node.name], node, source, buffer_name)
275
+ build_node(:arg, [parameter_name(node)], node, source, buffer_name)
259
276
  end
260
277
  end
261
278
 
data/lib/fast/scan.rb CHANGED
@@ -25,10 +25,14 @@ module Fast
25
25
  def scan
26
26
  files = Fast.ruby_files_from(*@locations)
27
27
  grouped = files.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |file, memo|
28
- entries = flatten_entries(Fast.summary(IO.read(file), file: file, command_name: @command_name).outline)
29
- next if entries.empty?
28
+ begin
29
+ entries = flatten_entries(Fast.summary(IO.read(file), file: file, command_name: @command_name).outline)
30
+ next if entries.empty?
30
31
 
31
- memo[classify(file, entries)] << [file, entries]
32
+ memo[classify(file, entries)] << [file, entries]
33
+ rescue StandardError => e
34
+ warn "Error scanning #{file}: #{e.message}" if Fast.debugging
35
+ end
32
36
  end
33
37
 
34
38
  print_grouped(grouped)
data/lib/fast/summary.rb CHANGED
@@ -21,7 +21,12 @@ module Fast
21
21
  if unsupported_template?
22
22
  nil
23
23
  elsif code_or_ast.is_a?(String)
24
- Fast.parse_ruby(code_or_ast, buffer_name: file || '(string)')
24
+ begin
25
+ Fast.parse_ruby(code_or_ast, buffer_name: file || '(string)')
26
+ rescue StandardError => e
27
+ warn "Error parsing #{file || 'source'}: #{e.message}" if Fast.debugging
28
+ nil
29
+ end
25
30
  else
26
31
  code_or_ast
27
32
  end
data/lib/fast/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fast
4
- VERSION = '0.2.3'
4
+ VERSION = '0.2.4'
5
5
  end
data/lib/fast.rb CHANGED
@@ -220,12 +220,24 @@ module Fast
220
220
  # @return [Hash[String, Array]] with files and results
221
221
  def group_results(group_files, locations, parallel: true)
222
222
  files = ruby_files_from(*locations)
223
- if parallel
224
- require 'parallel' unless defined?(Parallel)
225
- Parallel.map(files, &group_files)
226
- else
227
- files.map(&group_files)
228
- end.compact.inject(&:merge!)
223
+ results =
224
+ if parallel
225
+ require 'parallel' unless defined?(Parallel)
226
+ Parallel.map(files) do |file|
227
+ group_files.call(file)
228
+ rescue StandardError => e
229
+ warn "Error processing #{file}: #{e.message}" if Fast.debugging
230
+ nil
231
+ end
232
+ else
233
+ files.map do |file|
234
+ group_files.call(file)
235
+ rescue StandardError => e
236
+ warn "Error processing #{file}: #{e.message}" if Fast.debugging
237
+ nil
238
+ end
239
+ end
240
+ results.compact.inject(&:merge!) || {}
229
241
  end
230
242
 
231
243
  # Capture elements from searches in files. Keep in mind you need to use `$`
@@ -275,7 +287,11 @@ module Fast
275
287
  end
276
288
 
277
289
  def expression(string)
278
- ExpressionParser.new(string).parse
290
+ parser = ExpressionParser.new(string)
291
+ res = parser.parse
292
+ raise SyntaxError, parser.error_message if parser.tokens_left?
293
+
294
+ res
279
295
  end
280
296
 
281
297
  attr_accessor :debugging
@@ -444,6 +460,7 @@ module Fast
444
460
  # @param expression [String]
445
461
  def initialize(expression)
446
462
  @tokens = expression.scan TOKENIZER
463
+ @nesting = []
447
464
  end
448
465
 
449
466
  # rubocop:disable Metrics/CyclomaticComplexity
@@ -451,22 +468,43 @@ module Fast
451
468
  # rubocop:disable Metrics/MethodLength
452
469
  def parse
453
470
  case (token = next_token)
454
- when '(' then parse_until_peek(')')
455
- when '{' then Any.new(parse_until_peek('}'))
456
- when '[' then All.new(parse_until_peek(']'))
471
+ when '('
472
+ @nesting << ')'
473
+ parse_until_peek(')')
474
+ when '{'
475
+ @nesting << '}'
476
+ Any.new(parse_until_peek('}'))
477
+ when '['
478
+ @nesting << ']'
479
+ All.new(parse_until_peek(']'))
480
+ when ')', '}', ']'
481
+ raise SyntaxError, "Unexpected token: #{token}"
457
482
  when /^"/ then FindString.new(token[1..-2])
458
483
  when /^#\w/ then MethodCall.new(token[1..])
459
484
  when /^\.\w[\w\d_]+\?/ then InstanceMethodCall.new(token[1..])
460
485
  when '$' then Capture.new(parse)
461
- when '!' then (@tokens.any? ? Not.new(parse) : Find.new(token))
486
+ when '!' then (@tokens.any? && !closing_token?(@tokens.first) ? Not.new(parse) : Find.new(token))
462
487
  when '?' then Maybe.new(parse)
463
488
  when '^' then Parent.new(parse)
464
489
  when '\\' then FindWithCapture.new(parse)
465
490
  when /^%\d/ then FindFromArgument.new(token[1..])
491
+ when nil then nil
466
492
  else Find.new(token)
467
493
  end
468
494
  end
469
495
 
496
+ def tokens_left?
497
+ @tokens.any? || @nesting.any?
498
+ end
499
+
500
+ def error_message
501
+ if @tokens.any?
502
+ "Unconsumed tokens after parsing: #{@tokens.join(' ')}"
503
+ elsif @nesting.any?
504
+ "Unclosed nesting: expected #{@nesting.reverse.join(', ')}"
505
+ end
506
+ end
507
+
470
508
  # rubocop:enable Metrics/CyclomaticComplexity
471
509
  # rubocop:enable Metrics/AbcSize
472
510
  # rubocop:enable Metrics/MethodLength
@@ -477,10 +515,19 @@ module Fast
477
515
  @tokens.shift
478
516
  end
479
517
 
518
+ def closing_token?(token)
519
+ [')', '}', ']'].include?(token)
520
+ end
521
+
480
522
  def parse_until_peek(token)
481
523
  list = []
482
524
  list << parse until @tokens.empty? || @tokens.first == token
483
- next_token
525
+ last = next_token
526
+ if last == token
527
+ @nesting.pop
528
+ else
529
+ raise SyntaxError, "Expected #{token} but got #{last || 'end of string'}"
530
+ end
484
531
  list
485
532
  end
486
533
  end
@@ -503,8 +550,14 @@ module Fast
503
550
  when Find then expression.match?(node)
504
551
  when Symbol then compare_symbol_or_head(expression, node)
505
552
  when Enumerable
506
- expression.each_with_index.all? do |exp, i|
507
- match_recursive(exp, i.zero? ? node : node.children[i - 1])
553
+ if expression.last == :'...' || expression.last.is_a?(Find) && expression.last.token == '...'
554
+ expression[0...-1].each_with_index.all? do |exp, i|
555
+ match_recursive(exp, i.zero? ? node : node.children[i - 1])
556
+ end
557
+ else
558
+ expression.each_with_index.all? do |exp, i|
559
+ match_recursive(exp, i.zero? ? node : node.children[i - 1])
560
+ end
508
561
  end
509
562
  else
510
563
  node == expression
@@ -798,10 +851,17 @@ module Fast
798
851
 
799
852
  # @return [true] if all children matches with tail
800
853
  def match_tail?(tail, child)
801
- tail.each_with_index.all? do |token, i|
802
- prepare_token(token)
803
- token.is_a?(Array) ? match?(token, child[i]) : token.match?(child[i])
804
- end && find_captures
854
+ if tail.last.is_a?(Find) && tail.last.token == '...'
855
+ tail[0...-1].each_with_index.all? do |token, i|
856
+ prepare_token(token)
857
+ token.is_a?(Array) ? match?(token, child[i]) : token.match?(child[i])
858
+ end && find_captures
859
+ else
860
+ tail.each_with_index.all? do |token, i|
861
+ prepare_token(token)
862
+ token.is_a?(Array) ? match?(token, child[i]) : token.match?(child[i])
863
+ end && find_captures
864
+ end
805
865
  end
806
866
 
807
867
  # Look recursively into @param expression to check if the expression is have
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ffast
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jônatas Davi Paganini
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-30 00:00:00.000000000 Z
11
+ date: 2026-03-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: coderay
@@ -243,6 +243,7 @@ executables:
243
243
  extensions: []
244
244
  extra_rdoc_files: []
245
245
  files:
246
+ - ".agents/fast-pattern-expert/SKILL.md"
246
247
  - ".github/workflows/release.yml"
247
248
  - ".github/workflows/ruby.yml"
248
249
  - ".gitignore"
@@ -265,6 +266,7 @@ files:
265
266
  - bin/fast-mcp
266
267
  - bin/setup
267
268
  - fast.gemspec
269
+ - ideia_blog_post.md
268
270
  - lib/fast.rb
269
271
  - lib/fast/cli.rb
270
272
  - lib/fast/experiment.rb