Class: Mbeditor::SearchReplaceService

Inherits:
Object
  • Object
show all
Defined in:
app/services/mbeditor/search_replace_service.rb

Overview

Project-wide text search and replace-in-files.

Search runs through a three-tier backend picked per call:

rg (fastest) > git grep (fast, respects .gitignore) > grep (portable).

A single subprocess per query collects the full (capped) result set, which is cached briefly so pagination serves pages from memory instead of re-scanning the workspace. A new query supersedes (kills) any search still running for the same workspace so stacked keystrokes can't pile up concurrent full-tree scans.

Constant Summary collapse

MAX_REPLACE_FILES =
500
PER_FILE_TIMEOUT =
5
MAX_RESULTS =

Full-result cap per query. Also the ceiling for reported total counts.

10_000
RESULT_CACHE_TTL =

seconds; also invalidated on mbeditor file mutations

30
RESULT_CACHE_MAX_ENTRIES =
3

Class Method Summary collapse

Class Method Details

.count(workspace_root, query, use_regex:, match_case:, whole_word:, excluded_paths:) ⇒ Object



71
72
73
74
75
76
# File 'app/services/mbeditor/search_replace_service.rb', line 71

def count(workspace_root, query, use_regex:, match_case:, whole_word:, excluded_paths:)
  full_results(workspace_root, query, use_regex: use_regex, match_case: match_case,
               whole_word: whole_word, excluded_paths: excluded_paths)[:results].length
rescue StandardError
  0
end

.invalidate_cache(workspace_root = nil) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
# File 'app/services/mbeditor/search_replace_service.rb', line 78

def invalidate_cache(workspace_root = nil)
  STATE_MUTEX.synchronize do
    @result_cache ||= {}
    if workspace_root
      root = workspace_root.to_s
      @result_cache.delete_if { |key, _| key[0] == root }
    else
      @result_cache = {}
    end
  end
end

.replace(workspace_root, query, replacement, use_regex:, match_case:, whole_word:, excluded_paths:) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'app/services/mbeditor/search_replace_service.rb', line 90

def replace(workspace_root, query, replacement, use_regex:, match_case:, whole_word:, excluded_paths:)
  workspace_root = workspace_root.to_s

  raw_results = search(workspace_root, query, limit: MAX_RESULTS, use_regex: use_regex,
                       match_case: match_case, whole_word: whole_word, excluded_paths: excluded_paths)
  file_paths = raw_results.map { |r| r[:file] }.uniq

  if file_paths.length > MAX_REPLACE_FILES
    return { error: "Too many files matched (#{file_paths.length}). Narrow your search.",
             replaced_count: 0, files_affected: [], errors: [], partial: false }
  end

  pattern = build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
  matcher = ExclusionMatcher.new(excluded_paths)
  replaced_count = 0
  files_affected = []
  errors = []

  file_paths.each do |rel_path|
    full_path = File.join(workspace_root, rel_path)

    next if matcher.excluded?(rel_path)

    unless File.file?(full_path)
      errors << { file: rel_path, error: "File not found" }
      next
    end

    if File.size(full_path) > FileOperationService::MAX_FILE_SIZE_BYTES
      errors << { file: rel_path, error: "File too large" }
      next
    end

    begin
      Timeout.timeout(PER_FILE_TIMEOUT) do
        content = File.binread(full_path).force_encoding("UTF-8")
                     .encode("UTF-8", invalid: :replace, undef: :replace)
        replacements_in_file = content.scan(pattern).length
        new_content = content.gsub(pattern, replacement)
        if new_content != content
          File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
          files_affected << rel_path
          replaced_count += replacements_in_file
        end
      end
    rescue Timeout::Error
      errors << { file: rel_path, error: "Timed out processing file" }
    rescue StandardError => e
      errors << { file: rel_path, error: e.message }
    end
  end

  invalidate_cache(workspace_root) if files_affected.any?

  {
    replaced_count: replaced_count,
    files_affected: files_affected,
    errors: errors,
    partial: errors.any? && files_affected.any?
  }
rescue RegexpError => e
  { error: "Invalid regex: #{e.message}", replaced_count: 0, files_affected: [], errors: [], partial: false }
end

.respect_gitignore?Boolean

Whether searches should honour .gitignore. Shared by CodeSearchService so the two search paths can't drift apart again.

Returns:

  • (Boolean)


31
32
33
# File 'app/services/mbeditor/search_replace_service.rb', line 31

def respect_gitignore?
  Mbeditor.configuration.search_respect_gitignore == true
end

.rg_available?Boolean

Delegated so there is one rg probe for the whole engine. It is lazy (not frozen at boot) and re-checks negative results, so installing ripgrep while the server runs is picked up within a minute.

Returns:

  • (Boolean)


38
39
40
# File 'app/services/mbeditor/search_replace_service.rb', line 38

def rg_available?
  AvailabilityProbe.rg
end

.search(workspace_root, query, limit:, use_regex:, match_case:, whole_word:, excluded_paths:, paths: nil) ⇒ Object

Returns up to limit result rows. When paths is given the scan is restricted to those files (used by the live result-refresh) and the result cache is bypassed.



45
46
47
48
49
50
51
52
53
54
55
# File 'app/services/mbeditor/search_replace_service.rb', line 45

def search(workspace_root, query, limit:, use_regex:, match_case:, whole_word:, excluded_paths:, paths: nil)
  if paths
    return scan(workspace_root, query, use_regex: use_regex, match_case: match_case,
                whole_word: whole_word, excluded_paths: excluded_paths,
                paths: paths, max: limit)[:results]
  end

  full = full_results(workspace_root, query, use_regex: use_regex, match_case: match_case,
                      whole_word: whole_word, excluded_paths: excluded_paths)
  full[:results].first(limit)
end

.search_page(workspace_root, query, offset:, limit:, use_regex:, match_case:, whole_word:, excluded_paths:) ⇒ Object

Page into the cached full result set. total_count is only reported when the scan completed (not capped, not timed out).



59
60
61
62
63
64
65
66
67
68
69
# File 'app/services/mbeditor/search_replace_service.rb', line 59

def search_page(workspace_root, query, offset:, limit:, use_regex:, match_case:, whole_word:, excluded_paths:)
  full = full_results(workspace_root, query, use_regex: use_regex, match_case: match_case,
                      whole_word: whole_word, excluded_paths: excluded_paths)
  results = full[:results]
  {
    results: results[offset, limit] || [],
    has_more: results.length > offset + limit,
    total_count: full[:complete] ? results.length : nil,
    partial: !full[:complete]
  }
end