onlylogs 0.5.3 → 0.8.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.
@@ -1,12 +1,63 @@
1
+ require "timeout"
2
+
1
3
  module Onlylogs
2
4
  class Grep
3
- def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false, &block)
4
- # Use the appropriate script based on configuration
5
+ # Raised when a search runs past its own +timeout+. It inherits from
6
+ # Timeout::Error so callers that already wrap searches in Timeout.timeout
7
+ # can keep a single rescue.
8
+ class TimeoutError < ::Timeout::Error; end
9
+
10
+ # +timeout+ is in seconds and defaults to nil, which is no deadline at all:
11
+ # the search runs until it finishes or the caller abandons it. The default
12
+ # is deliberately not a number, because only the caller knows how long it
13
+ # can afford to hold the thread it runs on. Anything serving a request
14
+ # should pass one.
15
+ def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
16
+ max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
17
+ command_args = search_command(pattern, file_path, start_position: start_position,
18
+ end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
19
+
20
+ results = []
21
+
22
+ # Set up parsing logic based on whether ripgrep includes byte offsets
23
+ parse_line = if Onlylogs.ripgrep_enabled?
24
+ ->(line) {
25
+ parts = line.split(":", 2)
26
+ [parts[0].to_i + start_position, parts[1] || ""]
27
+ }
28
+ else
29
+ ->(line) { [nil, line] }
30
+ end
31
+
32
+ begin
33
+ each_output_line(command_args, timeout: timeout) do |line|
34
+ byte_offset, content = parse_line.call(line.chomp)
35
+
36
+ # Use String.new to create a copy and prevent memory retention from IO buffers
37
+ content = String.new(content, encoding: Encoding::UTF_8).scrub
38
+
39
+ result = {byte_offset: byte_offset, content: content}
40
+
41
+ if block_given?
42
+ yield result
43
+ else
44
+ results << result
45
+ end
46
+ end
47
+ ensure
48
+ drop_page_cache(file_path)
49
+ end
50
+
51
+ block_given? ? nil : results
52
+ end
53
+
54
+ def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
55
+ max_matches: Onlylogs.max_line_matches)
5
56
  script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
6
57
  super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
7
58
 
8
59
  command_args = [super_grep_path]
9
- command_args += ["--max-matches", Onlylogs.max_line_matches.to_s] if Onlylogs.max_line_matches.present?
60
+ command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
10
61
  command_args << "--regexp" if regexp_mode
11
62
 
12
63
  # Add byte range parameters if specified
@@ -15,25 +66,89 @@ module Onlylogs
15
66
  command_args << "--end-position" << end_position.to_s if end_position
16
67
  end
17
68
 
18
- command_args += [pattern, file_path]
69
+ command_args + [pattern, file_path]
70
+ end
19
71
 
20
- results = []
72
+ # Runs the search subprocess and yields its output line by line.
73
+ #
74
+ # The child is a shell pipeline (tail | head | rg) that can spend minutes
75
+ # scanning a multi-GB file, so two things have to hold. timeout(1) bounds
76
+ # the run and escalates TERM to KILL on the whole pipeline by itself, which
77
+ # covers the deadline. The rest is the caller walking away early - a break
78
+ # out of the yield, an exception, a dropped connection - and there the order
79
+ # below is the point: signal the process group *before* closing the pipe.
80
+ # Closing first waits for a child that, having matched nothing, never wrote
81
+ # and so never received SIGPIPE. That is how a 25 second timeout once turned
82
+ # into a 24 minute request.
83
+ def self.each_output_line(command_args, timeout: nil, &block)
84
+ reader, writer = IO.pipe
21
85
 
22
- IO.popen(command_args, err: "/dev/null") do |io|
23
- io.each_line do |line|
24
- # Line numbers are no longer outputted by super_grep/super_ripgrep
25
- # Use String.new to create a copy and prevent memory retention from IO buffers
26
- content = String.new(line.chomp, encoding: Encoding::UTF_8).scrub
86
+ begin
87
+ pid = Process.spawn(*bounded(command_args, timeout), out: writer, err: ::File::NULL, pgroup: true)
88
+ rescue
89
+ reader.close
90
+ raise
91
+ ensure
92
+ writer.close
93
+ end
27
94
 
28
- if block_given?
29
- yield content
30
- else
31
- results << content
32
- end
95
+ begin
96
+ reader.each_line(&block)
97
+ ensure
98
+ status = stop(pid)
99
+ reader.close
100
+ end
101
+
102
+ raise TimeoutError, "search exceeded #{timeout}s" if status&.exitstatus == TIMED_OUT_EXIT_STATUS
103
+ end
104
+
105
+ # timeout(1) exits with this when it had to stop the command.
106
+ TIMED_OUT_EXIT_STATUS = 124
107
+ KILL_GRACE_PERIOD = 0.5
108
+
109
+ def self.bounded(command_args, timeout)
110
+ return command_args unless timeout && timeout_command_available?
111
+
112
+ ["timeout", "-k", KILL_GRACE_PERIOD.to_s, timeout.to_s, *command_args]
113
+ end
114
+
115
+ # Not part of a BSD userland, so a machine without GNU coreutils falls back
116
+ # to whatever deadline the caller imposes. The kill path below still works.
117
+ def self.timeout_command_available?
118
+ return @timeout_command_available if defined?(@timeout_command_available)
119
+
120
+ @timeout_command_available = system("command -v timeout > /dev/null 2>&1")
121
+ end
122
+
123
+ # Runs while unwinding from an async exception often enough that a second
124
+ # one - another Timeout::Error, a Thread#kill from a shutting down server -
125
+ # could otherwise land between the signal and the reap and leave the
126
+ # pipeline running with nobody left to stop it.
127
+ def self.stop(pid)
128
+ Thread.handle_interrupt(::Exception => :never) do
129
+ begin
130
+ Process.kill("TERM", -pid)
131
+ rescue Errno::ESRCH, Errno::EPERM
132
+ nil
33
133
  end
134
+
135
+ Process.waitpid2(pid).last
34
136
  end
137
+ rescue Errno::ECHILD
138
+ nil
139
+ end
35
140
 
36
- block_given? ? nil : results
141
+ # Searching a large log file pulls the whole file into the OS page cache.
142
+ # In a container the kernel charges that cache to the cgroup, so a few
143
+ # searches over multi-GB logs can exhaust the memory limit and trigger an
144
+ # OOM kill even though no Ruby memory leaked. Hint the kernel to drop the
145
+ # pages we just read, once the child is gone and nothing is refilling them.
146
+ # Best-effort: advise is only a hint and is unsupported on some platforms,
147
+ # so never let it break a search.
148
+ def self.drop_page_cache(file_path)
149
+ ::File.open(file_path) { |file| file.advise(:dontneed) }
150
+ rescue
151
+ nil
37
152
  end
38
153
 
39
154
  def self.match_line?(line, string, regexp_mode: false)
@@ -36,5 +36,5 @@
36
36
  <% end %>
37
37
  <% end %>
38
38
  </div>
39
- <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode } %>
39
+ <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path } %>
40
40
  </div>
@@ -1,11 +1,18 @@
1
- <%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false) %>
1
+ <%# locals: (log_file_path:) %>
2
2
  <script nonce="<%= request.content_security_policy_nonce %>"
3
- src="https://cdn.jsdelivr.net/npm/clusterize.js@0.18.1/clusterize.min.js"></script>
3
+ src="<%= asset_path('onlylogs/clusterize.js') %>"></script>
4
+ <link rel="stylesheet" href="<%= asset_path('onlylogs/clusterize.css') %>" />
4
5
  <%= render "onlylogs/shared/log_container_styles" %>
5
6
 
6
7
  <%
7
- mode = filter.blank? ? "live" : "search"
8
- cursor_position = mode == "search" ? 0 : [File.size(log_file_path) - (tail * 100), 0].max
8
+ autoscroll = local_assigns[:autoscroll] != false
9
+ filter = local_assigns[:filter] || ""
10
+ regexp_mode = local_assigns[:regexp_mode] == true
11
+ mode = local_assigns[:mode] || "live"
12
+ file_size = File.size(log_file_path)
13
+ start_position = local_assigns[:start_position]
14
+ end_position = local_assigns[:end_position]
15
+ byte_offset = request.params[:byte_offset]
9
16
 
10
17
  raise SecurityError, "File path not allowed" unless Onlylogs.file_path_permitted?(log_file_path)
11
18
 
@@ -14,11 +21,13 @@
14
21
 
15
22
  <div data-controller="log-streamer text-selection keyboard-shortcuts"
16
23
  data-log-streamer-file-path-value="<%= encrypted_log_file_path %>"
17
- data-log-streamer-cursor-position-value="<%= cursor_position %>"
24
+ <% if start_position.present? %>data-log-streamer-start-position-value="<%= start_position %>"<% end %>
25
+ <% if end_position.present? %>data-log-streamer-end-position-value="<%= end_position %>"<% end %>
18
26
  data-log-streamer-filter-value="<%= filter %>"
19
27
  data-log-streamer-auto-scroll-value="<%= autoscroll %>"
20
28
  data-log-streamer-regexp-mode-value="<%= regexp_mode %>"
21
29
  data-log-streamer-mode-value="<%= mode %>"
30
+ data-log-streamer-file-size-value="<%= file_size %>"
22
31
  data-action="text-selection:start->log-streamer#pauseForSelection"
23
32
  class="onlylogs-log-container" >
24
33
  <div data-log-streamer-target="logLines" data-text-selection-target="logLines" id="scrollArea" class="onlylogs-log-lines clusterize-scroll">
@@ -28,44 +37,42 @@
28
37
 
29
38
  <button type="button"
30
39
  data-text-selection-target="button"
31
- class="onlylogs-context-menu"
40
+ class="onlylogs-context-menu hidden-button"
32
41
  data-action="click->text-selection#searchSelectedText"
33
- title="Search selected text"
34
- style="display: none;">
42
+ title="Search selected text">
35
43
  🔍 Search
36
44
  </button>
37
45
 
38
46
  <div class="onlylogs-log-toolbar">
39
47
  <div>
40
- <label style="margin-bottom: 0;">
48
+ <label class="toolbar-label">
41
49
  <input id="liveMode" type="checkbox" <%= mode == "live" ? "checked" : "" %> name="liveMode" data-log-streamer-target="liveMode" data-keyboard-shortcuts-target="liveMode" data-action="change->log-streamer#toggleLiveMode">
42
50
  <u>L</u>ive Mode
43
51
  </label>
44
52
  </div>
45
53
  <div>
46
- <label style="margin-bottom: 0;">
54
+ <label class="toolbar-label">
47
55
  <input id="autoscroll" type="checkbox" <%= autoscroll ? "checked" : "" %> name="autoscroll" data-log-streamer-target="autoscroll" data-keyboard-shortcuts-target="autoscroll" data-action="change->log-streamer#toggleAutoScroll">
48
56
  <u>A</u>utoscroll
49
57
  </label>
50
58
  </div>
51
59
  <div>
52
- <label style="margin-bottom: 0;">
60
+ <label class="toolbar-label">
53
61
  <input id="regexpMode" type="checkbox" <%= regexp_mode ? "checked" : "" %> name="regexpMode" data-log-streamer-target="regexpMode" data-text-selection-target="regexpMode" data-action="change->log-streamer#toggleRegexpMode">
54
62
  <u>R</u>egexp Mode
55
63
  </label>
56
64
  </div>
57
65
  <div>
58
- <label style="margin-bottom: 0;">
66
+ <label class="toolbar-label">
59
67
  Filter:
60
- <div style="display: inline-flex; align-items: center; position: relative;">
68
+ <div class="filter-input-wrapper">
61
69
  <input type="text"
62
70
  name="filter"
63
71
  value="<%= filter %>"
64
72
  placeholder="Enter filter text..."
65
73
  data-log-streamer-target="filterInput"
66
74
  data-text-selection-target="filterInput"
67
- data-action="input->log-streamer#applyFilter"
68
- style="padding-right: 1.5rem;">
75
+ data-action="input->log-streamer#applyFilter">
69
76
  <button type="button"
70
77
  data-action="click->log-streamer#clearFilter"
71
78
  class="clear-filter-button"
@@ -75,18 +82,34 @@
75
82
  </div>
76
83
  </label>
77
84
  </div>
85
+ <div class="range-slider-wrapper">
86
+ <%= render "onlylogs/shared/range_slider",
87
+ start_name: "start_position",
88
+ end_name: "end_position",
89
+ start_value: 0,
90
+ end_value: file_size,
91
+ min: 0,
92
+ max: file_size,
93
+ step: 1000,
94
+ label: "Range" %>
95
+ <button type="button"
96
+ data-action="click->log-streamer#resetRange"
97
+ class="reset-range-button"
98
+ title="Reset to full file">
99
+
100
+ </button>
101
+ </div>
78
102
  <div>
79
103
  <button type="button"
80
104
  data-log-streamer-target="stopButton"
105
+ class="stop-search-button hidden-button"
81
106
  data-action="click->log-streamer#stopSearch"
82
- class="stop-search-button"
83
- title="Stop current search"
84
- style="display: none;">
107
+ title="Stop current search">
85
108
  ⏹️ Stop
86
109
  </button>
87
110
  </div>
88
111
  <div>
89
- <span data-log-streamer-target="results" style="color: #666;">Results: 0</span>
112
+ <span data-log-streamer-target="results" class="results-text">Results: 0</span>
90
113
  </div>
91
114
  <div data-log-streamer-target="message"></div>
92
115
  <% unless Onlylogs.ripgrep_enabled? %>
@@ -95,11 +118,11 @@
95
118
  <div class="file-size" title="File size: <%= number_to_human_size(File.size(log_file_path)) %>">
96
119
  <%= number_to_human_size(File.size(log_file_path)) %>
97
120
  </div>
98
- <div style="width:67px">
121
+ <div class="clear-button-wrapper">
99
122
  <button type="button"
100
123
  data-log-streamer-target="clearButton"
101
- data-action="click->log-streamer#clearLogs"
102
124
  class="clear-logs-button"
125
+ data-action="click->log-streamer#clearLogs"
103
126
  title="Clear all log lines">
104
127
  🗑️ Clear
105
128
  </button>
@@ -5,7 +5,7 @@
5
5
  height: var(--onlylogs-height, 100%); /* Take full height of parent container */
6
6
  min-height: var(--onlylogs-min-height, 400px);
7
7
  display: grid;
8
- grid-template-rows: auto var(--onlylogs-toolbar-height, 42px);
8
+ grid-template-rows: auto var(--onlylogs-toolbar-height, 80px);
9
9
 
10
10
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
11
11
  font-size: var(--onlylogs-font-size, 0.8rem);
@@ -24,8 +24,25 @@
24
24
  }
25
25
  }
26
26
 
27
+ &[data-log-streamer-mode-value="static"] .onlylogs-expand-btn {
28
+ cursor: pointer;
29
+ padding: 0 0.5rem;
30
+ margin-right: 0.25rem;
31
+ background: none;
32
+ border: none;
33
+ color: #666;
34
+ font-weight: bold;
35
+ font-size: 1.1em;
36
+ line-height: 1;
37
+
38
+ &:hover {
39
+ color: #333;
40
+ }
41
+ }
42
+
27
43
  .clusterize-content {
28
44
  outline: 0;
45
+ min-width: max-content;
29
46
  }
30
47
 
31
48
  .color-success {
@@ -76,6 +93,11 @@
76
93
  margin: 0 !important;
77
94
  padding: 0.2rem;
78
95
  word-break: break-word; /* allow breaking long tokens like UUIDs/SQL */
96
+ white-space: pre;
97
+
98
+ &.highlighted-context-line {
99
+ background-color: rgba(255, 193, 7, 0.3);
100
+ }
79
101
  }
80
102
 
81
103
  a {
@@ -106,7 +128,11 @@
106
128
 
107
129
  &:hover {
108
130
  background-color: #bbdefb;
109
- }
131
+ }
132
+ }
133
+
134
+ .hidden-button {
135
+ display: none;
110
136
  }
111
137
 
112
138
  @keyframes spin {
@@ -116,20 +142,15 @@
116
142
 
117
143
  .onlylogs-log-toolbar {
118
144
  flex-shrink: 0;
119
- padding: 1rem 0.5rem;
120
- display: flex;
121
- align-items: center;
122
- gap: 1.5rem;
145
+ padding: 0.5rem;
146
+ display: flex;
147
+ flex-wrap: wrap;
148
+ align-items: center;
149
+ gap: 0.75rem;
123
150
 
124
- .live-mode-sticky {
125
- opacity: 0.7;
126
- cursor: not-allowed;
127
151
 
128
- input[type="checkbox"] {
129
- cursor: not-allowed;
130
- }
131
- }
132
152
 
153
+ }
133
154
  .clear-filter-button {
134
155
  position: absolute;
135
156
  right: 0.25rem;
@@ -217,6 +238,165 @@
217
238
  border-radius: 0.25rem;
218
239
  display: inline-block;
219
240
  }
241
+
242
+ .range-slider {
243
+ --range-track-height: 0.25rem;
244
+ --range-thumb-size: 0.6rem;
245
+ --range-color: #2563eb;
246
+ --range-bg: #d1d5db;
247
+ --range-start-percent: 0%;
248
+ --range-end-percent: 100%;
249
+
250
+ display: contents;
251
+ }
252
+
253
+ /* Toolbar utilities */
254
+ .toolbar-label {
255
+ margin-bottom: 0;
256
+ }
257
+
258
+ .filter-input-wrapper {
259
+ display: inline-flex;
260
+ align-items: center;
261
+ position: relative;
262
+ }
263
+
264
+ .filter-input-wrapper input {
265
+ padding-right: 1.5rem;
266
+ }
267
+
268
+ .clear-button-wrapper {
269
+ width: 67px;
270
+ }
271
+
272
+ .results-text {
273
+ color: #666;
274
+ }
275
+
276
+ .hidden-button {
277
+ display: none;
278
+ }
279
+
280
+ .range-slider-wrapper {
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 0.5rem;
284
+ min-width: 250px;
285
+ flex-shrink: 0;
286
+ }
287
+
288
+ .range-slider__container {
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 0.4rem;
292
+ flex: 1;
293
+ height: 1.5rem;
294
+ }
295
+
296
+ .range-slider__label {
297
+ font-size: 0.75rem;
298
+ font-weight: 500;
299
+ white-space: nowrap;
300
+ flex-shrink: 0;
301
+ }
302
+
303
+ .range-slider__slider {
304
+ display: grid;
305
+ gap: 0.15rem;
306
+ flex: 1;
307
+ }
308
+
309
+ .range-slider__values {
310
+ display: flex;
311
+ gap: 0.15rem;
312
+ font-size: 0.6rem;
313
+ font-variant-numeric: tabular-nums;
314
+ justify-content: center;
315
+ }
316
+
317
+ .range-slider__track {
318
+ position: relative;
319
+ height: var(--range-thumb-size);
320
+ }
321
+
322
+ .range-slider__track::before,
323
+ .range-slider__selected {
324
+ position: absolute;
325
+ top: 50%;
326
+ height: var(--range-track-height);
327
+ transform: translateY(-50%);
328
+ border-radius: 999px;
329
+ }
330
+
331
+ .range-slider__track::before {
332
+ content: "";
333
+ left: 0;
334
+ right: 0;
335
+ background: var(--range-bg);
336
+ }
337
+
338
+ .range-slider__selected {
339
+ left: var(--range-start-percent);
340
+ right: calc(100% - var(--range-end-percent));
341
+ background: var(--range-color);
342
+ }
343
+
344
+ .range-slider__input {
345
+ position: absolute;
346
+ inset: 0;
347
+ width: 100%;
348
+ appearance: none;
349
+ background: none;
350
+ pointer-events: none;
351
+ margin: 0;
352
+ }
353
+
354
+ .range-slider__input::-webkit-slider-thumb {
355
+ appearance: none;
356
+ width: var(--range-thumb-size);
357
+ height: var(--range-thumb-size);
358
+ border-radius: 50%;
359
+ background: var(--range-color);
360
+ cursor: pointer;
361
+ pointer-events: auto;
362
+ position: relative;
363
+ z-index: 2;
364
+ }
365
+
366
+ .range-slider__input::-moz-range-thumb {
367
+ width: var(--range-thumb-size);
368
+ height: var(--range-thumb-size);
369
+ border: 0;
370
+ border-radius: 50%;
371
+ background: var(--range-color);
372
+ cursor: pointer;
373
+ pointer-events: auto;
374
+ position: relative;
375
+ z-index: 2;
376
+ }
377
+
378
+ .reset-range-button {
379
+ background-color: transparent;
380
+ color: #666;
381
+ border: 1px solid #ccc;
382
+ padding: 0.3rem 0.5rem;
383
+ border-radius: 2px;
384
+ cursor: pointer;
385
+ font-size: 0.8rem;
386
+ transition: background-color 0.2s;
387
+ height: 1.5rem;
388
+ display: flex;
389
+ align-items: center;
390
+ flex-shrink: 0;
391
+
392
+ &:hover {
393
+ background-color: #f0f0f0;
394
+ }
395
+
396
+ &:active {
397
+ background-color: #e0e0e0;
398
+ }
399
+ }
220
400
  }
221
401
  }
222
402
  </style>
@@ -0,0 +1,47 @@
1
+ <%# app/views/onlylogs/shared/_range_slider.html.erb %>
2
+ <%# locals: (start_name:, end_name:, start_value:, end_value:, min: 0, max: 100, step: 1, label: nil, show_reset: false) %>
3
+
4
+ <div class="range-slider range-slider--double"
5
+ data-log-streamer-target="rangeSliderContainer"
6
+ style="--range-min: <%= min %>; --range-max: <%= max %>; --range-start: <%= start_value %>; --range-end: <%= end_value %>;">
7
+
8
+ <div class="range-slider__container">
9
+ <% if label.present? %>
10
+ <label class="range-slider__label">
11
+ <%= label %>
12
+ </label>
13
+ <% end %>
14
+
15
+ <div class="range-slider__slider">
16
+ <div class="range-slider__track">
17
+ <div class="range-slider__selected"></div>
18
+
19
+ <input class="range-slider__input range-slider__input--start"
20
+ type="range"
21
+ name="<%= start_name %>"
22
+ min="<%= min %>"
23
+ max="<%= max %>"
24
+ step="<%= step %>"
25
+ value="<%= start_value %>"
26
+ data-log-streamer-target="startSlider"
27
+ data-action="input->log-streamer#updateRangeVisuals input->range-slider#updateVisuals change->log-streamer#updateRangeVisuals change->range-slider#updateVisuals">
28
+
29
+ <input class="range-slider__input range-slider__input--end"
30
+ type="range"
31
+ name="<%= end_name %>"
32
+ min="<%= min %>"
33
+ max="<%= max %>"
34
+ step="<%= step %>"
35
+ value="<%= end_value %>"
36
+ data-log-streamer-target="endSlider"
37
+ data-action="input->log-streamer#updateRangeVisuals input->range-slider#updateVisuals change->log-streamer#updateRangeVisuals change->range-slider#updateVisuals">
38
+ </div>
39
+
40
+ <div class="range-slider__values">
41
+ <output data-log-streamer-target="startOutput"><%= start_value %></output>
42
+ <span>–</span>
43
+ <output data-log-streamer-target="endOutput"><%= end_value %></output>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
data/bin/super_grep CHANGED
@@ -5,7 +5,6 @@ export LC_ALL=C
5
5
  max_matches=""
6
6
  start_position=""
7
7
  end_position=""
8
- block_size="${BLOCK_SIZE:-8M}"
9
8
 
10
9
  while [[ $# -gt 0 ]]; do
11
10
  case "$1" in
@@ -64,26 +63,22 @@ fi
64
63
 
65
64
  # Handle byte range if specified
66
65
  if [ -n "$start_position" ] || [ -n "$end_position" ]; then
66
+ case "${start_position:-0}${end_position:-0}" in
67
+ *[!0-9]*) exit 1 ;;
68
+ esac
69
+
67
70
  file_size=$(wc -c < "$file")
68
71
  range_start=${start_position:-0}
69
72
  range_end=${end_position:-$file_size}
70
73
  range_size=$((range_end - range_start))
71
-
72
- # Validate range
73
- if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
74
+
75
+ if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
74
76
  exit 0
75
77
  fi
76
-
77
- # Adjust if exceeds file size
78
+
78
79
  [ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
79
-
80
- # Extract byte range using dd
81
- start_mb=$((range_start / 1048576))
82
- start_offset=$((range_start % 1048576))
83
- count_mb=$(((range_size + 1048576 - 1) / 1048576))
84
-
85
- dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
86
- dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
80
+
81
+ tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
87
82
  "${grep_cmd[@]}" "$query_regex"
88
83
  else
89
84
  # Search entire file