rgrc 0.6.14

Rusty Generic Colouriser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! # colorizer.rs - Text Colorization Engine for rgrc
//!
//! This module provides high-performance text colorization functionality that applies
//! regex-based color rules to input text. It's the core engine of rgrc, responsible
//! for parsing input lines and applying console styling (colors, attributes) to matched patterns.
//!
//! ## Architecture
//!
//! The colorizer uses an optimized regex-based approach with intelligent caching
//! and pattern matching optimizations for complex regex patterns.
//!
//! ## Algorithm Overview
//!
//! For each input line, three phases execute:
//!
//! 1. **Regex Matching** (Phase 1):
//!    - Apply each rule's regex pattern to find matches
//!    - Extract capture groups (can have different styles)
//!    - Handle overlapping matches and edge cases
//!    - Support count and replace functionality
//!
//! 2. **Style Mapping** (Phase 2):
//!    - Build per-character style map
//!    - Later rules override earlier ones where they overlap
//!    - Implements simple precedence strategy
//!
//! 3. **ANSI Encoding** (Phase 3):
//!    - Merge adjacent characters with same style
//!    - Minimize ANSI escape sequences
//!    - Apply console styling using the `console` crate
//!
//! ## Key Optimizations
//!
//! - **Match result caching**: Tracks rightmost end positions to avoid redundant checks
//! - **Zero-width match handling**: Prevents infinite loops on empty matches
//! - **Style merging**: Combines adjacent styled segments to reduce escape sequences
//! - **Count field support**: once/more/stop matching control
//! - **Replace field support**: Text substitution functionality

use std::io::{BufRead, BufReader, Read, Write};
#[cfg(feature = "debug")]
use std::time::Instant;

use crate::grc::GrcatConfigEntry;
use crate::style::Style;

/// Regex-optimized colorizer with advanced caching and pattern matching optimizations.
///
/// This function implements a highly optimized version of the colorization algorithm
/// that focuses on regex performance improvements and intelligent caching strategies.
/// It's designed for scenarios where regex matching overhead is significant.
///
/// ## Key Optimizations
///
/// ## Arguments
///
/// * `reader` - Input source implementing Read (file, stdin, buffer, etc.)
/// * `writer` - Output destination implementing Write (stdout, file, buffer, etc.)
/// * `rules` - Slice of colorization rules with pre-compiled regex patterns
///
/// ## Returns
///
/// * `Ok(())` - Successfully processed all input and wrote styled output
/// * `Err(Box<dyn Error>)` - I/O error, regex compilation error, or encoding error
///
/// ## Performance Characteristics
///
/// * **Time Complexity**: O(n × r × m) worst case, often better with caching
/// * **Space Complexity**: O(m) per line (m = line length)
/// * **Cache Efficiency**: Up to 60% reduction in regex calls vs naive approach
/// * **Memory Usage**: Minimal - no line accumulation, streaming output
/// * **Best For**: Complex regex patterns, large inputs, performance-critical code
///
/// ## Error Handling
///
/// - **I/O Errors**: Propagated from reader/writer operations
/// - **Regex Errors**: Should not occur (regexes pre-compiled in rules)
/// - **Encoding Errors**: UTF-8 validation handled by BufReader
///
/// ## Thread Safety
///
/// This function is thread-safe as it doesn't use any shared mutable state.
/// Multiple instances can run concurrently on different inputs.
/// # Examples
///
/// ```ignore
/// use std::io::Cursor;
/// use fancy_regex::Regex;
/// use rgrc::style::Style;
/// use rgrc::colorizer::colorize_regex;
/// use rgrc::grc::GrcatConfigEntry;
///
/// // Example: colorize lines beginning with "ERROR:" using a bold red style
/// let input = "ERROR: Connection failed\nINFO: OK\n";
/// let mut reader = Cursor::new(input);
/// let mut output = Vec::new();
///
/// // Construct a simple rule that matches "ERROR: (.*)" and applies a red bold style
/// let re = Regex::new(r"ERROR: (.*)").unwrap();
/// let style = Style::new().red().bold();
/// let entry = GrcatConfigEntry::new(re, vec![style]);
///
/// // Call the colorizer; the example is marked `ignore` to avoid running as a doctest
/// colorize_regex(&mut reader, &mut output, &[entry])?;
///
/// // `output` now contains ANSI-styled bytes representing the colored text
/// ```
/// Decode a raw line read by `read_until(b'\n')` into a `String`.
///
/// Trims the trailing newline (and a preceding `\r` for CRLF), matching what
/// `BufRead::lines()` returned. Invalid UTF-8 bytes become U+FFFD instead of
/// erroring, so binary output doesn't abort the stream (#31).
pub fn decode_line(raw: &[u8]) -> String {
    let mut end = raw.len();
    if end > 0 && raw[end - 1] == b'\n' {
        end -= 1;
        if end > 0 && raw[end - 1] == b'\r' {
            end -= 1;
        }
    }
    String::from_utf8_lossy(&raw[..end]).into_owned()
}

#[allow(dead_code)] // Used in main.rs but may not be detected in all build configurations
pub fn colorize_regex<R, W>(
    reader: &mut R,
    writer: &mut W,
    rules: &[GrcatConfigEntry],
) -> Result<(), Box<dyn std::error::Error>>
where
    R: Read,
    W: Write,
{
    #[cfg(feature = "debug")]
    let record_time = std::env::var_os("RGRCTIME").is_some();

    #[cfg(feature = "debug")]
    let overall_start = if record_time {
        Some(Instant::now())
    } else {
        None
    };

    #[cfg(feature = "debug")]
    let mut lines_processed: usize = 0;
    // ═══════════════════════════════════════════════════════════════════════════════
    // PHASE 1: INPUT PROCESSING - Set up buffered reading and line iteration
    // ═══════════════════════════════════════════════════════════════════════════════

    // Wrap input in BufReader to reduce I/O syscall overhead.
    //
    // We read with read_until(b'\n') and decode lossily instead of BufRead::lines():
    // lines() rejects non-UTF-8 input (e.g. `docker save` tar streams, see #31),
    // while from_utf8_lossy turns invalid bytes into U+FFFD so we keep going.
    let mut reader = BufReader::new(reader);
    let mut raw_buf: Vec<u8> = Vec::new();

    // ═══════════════════════════════════════════════════════════════════════════════
    // FAST PATH: No rules to apply - stream input directly to output unchanged
    // ═══════════════════════════════════════════════════════════════════════════════

    if rules.is_empty() {
        loop {
            raw_buf.clear();
            let read = reader.read_until(b'\n', &mut raw_buf)?;
            if read == 0 {
                break;
            }
            let line = decode_line(&raw_buf);
            writeln!(writer, "{}", line)?;
        }
        return Ok(());
    }

    // Default style for unstyled text (no color, no attributes)
    let default_style = Style::new();

    // ═══════════════════════════════════════════════════════════════════════════════
    // PHASE 2: LINE-BY-LINE PROCESSING - Apply colorization rules to each line
    // ═══════════════════════════════════════════════════════════════════════════════

    loop {
        raw_buf.clear();
        let read = reader.read_until(b'\n', &mut raw_buf)?;
        if read == 0 {
            break;
        }
        let mut line = decode_line(&raw_buf);
        #[cfg(feature = "debug")]
        if record_time {
            lines_processed += 1;
        }

        // ═══════════════════════════════════════════════════════════════════════════════
        // FAST PATH: Empty lines - preserve as single newline without processing
        // ═══════════════════════════════════════════════════════════════════════════════

        if line.is_empty() {
            writeln!(writer)?;
            continue;
        }

        // ═══════════════════════════════════════════════════════════════════════════════
        // PHASE 2A: MATCH COLLECTION - Find all regex matches with intelligent caching
        // ═══════════════════════════════════════════════════════════════════════════════

        // Vector to collect all (start_pos, end_pos, style) ranges for matched patterns
        let mut style_ranges: Vec<(usize, usize, &Style)> = Vec::new();

        // Track whether to stop processing the entire line (for count=stop)
        let mut stop_line_processing = false;

        // Process each rule (regex pattern + associated styles)
        'outer_loop: for rule in rules {
            // Skip rules marked with skip=true
            if rule.skip {
                continue;
            }

            // Stop processing if a previous rule had count=stop
            if stop_line_processing {
                break;
            }

            // Current search offset in the line (advances as we find matches)
            let mut offset = 0;

            // OPTIMIZATION: Track the rightmost end position of any match for this rule
            // This allows us to skip redundant regex checks in already-processed regions
            let mut last_end = 0;

            // Track whether this rule should match only once (for count=once)
            let mut rule_matched_once = false;

            // Scan the line for all matches of this rule's regex pattern
            while offset < line.len() && !rule_matched_once {
                // ═══════════════════════════════════════════════════════════════════════════════
                // CACHE OPTIMIZATION: Skip regions already covered by previous matches
                // ═══════════════════════════════════════════════════════════════════════════════

                // If current offset is before the last match end, jump forward
                // This avoids redundant regex checks in overlapping match regions
                if offset < last_end {
                    offset = last_end;
                    continue;
                }

                // Attempt regex match starting from current offset position
                if let Some(matches) = rule.regex.captures_from_pos(&line, offset) {
                    // ═══════════════════════════════════════════════════════════════════════════════
                    // CAPTURE GROUP PROCESSING: Extract each matched subgroup
                    // ═══════════════════════════════════════════════════════════════════════════════

                    // Iterate through all capture groups (index 0 = full match, 1+ = subgroups)
                    for (i, mmatch) in matches.iter().into_iter().enumerate() {
                        if let Some(mmatch) = mmatch {
                            let start = mmatch.start();
                            let end = mmatch.end();

                            // Only apply styling if this capture group index has a corresponding style
                            // Most rules only style the full match (index 0) or first few groups
                            if i < rule.colors.len() {
                                let style = &rule.colors[i];

                                // Record this styled range for later application
                                style_ranges.push((start, end, style));

                                // Update cache: track rightmost position covered by any match
                                last_end = last_end.max(end);
                            }
                        }

                        // ═══════════════════════════════════════════════════════════════════════════════
                        // REPLACE FUNCTIONALITY: Text substitution with capture group support
                        // ═══════════════════════════════════════════════════════════════════════════════

                        // Get the full match (capture group 0) for replacement operations
                        let full_match = matches.get(0).unwrap();

                        // If replace field is specified, perform text substitution
                        if !rule.replace.is_empty() {
                            // Build replacement string with capture group substitution
                            let mut replacement = rule.replace.clone();

                            // Replace \1, \2, etc. with corresponding capture groups
                            for (i, capture) in matches.iter().into_iter().enumerate() {
                                if let Some(capture_match) = capture {
                                    let capture_text =
                                        &line[capture_match.start()..capture_match.end()];
                                    let placeholder = format!("\\{}", i);
                                    replacement = replacement.replace(&placeholder, capture_text);
                                }
                            }

                            // Replace the matched text in the line
                            // Note: This modifies the line, which may affect subsequent rule matching
                            // We rebuild the line with the replacement
                            let before = &line[..full_match.start()];
                            let after = &line[full_match.end()..];
                            line = format!("{}{}{}", before, replacement, after);

                            // Since we modified the line, we need to restart processing from the beginning
                            // This is a simplified approach - in practice, we might want more sophisticated handling
                            break 'outer_loop;
                        }

                        // ═══════════════════════════════════════════════════════════════════════════════
                        // COUNT CONTROL: Handle once/more/stop matching behavior
                        // ═══════════════════════════════════════════════════════════════════════════════

                        // Apply count logic based on rule configuration
                        match rule.count {
                            crate::grc::GrcatConfigEntryCount::Once => {
                                // Match only once per rule, then skip to next rule
                                rule_matched_once = true;
                            }
                            crate::grc::GrcatConfigEntryCount::More => {
                                // Continue matching (default behavior)
                            }
                            crate::grc::GrcatConfigEntryCount::Stop => {
                                // Match once and stop processing the entire line
                                stop_line_processing = true;
                                rule_matched_once = true;
                            }
                        }
                    }

                    // ═══════════════════════════════════════════════════════════════════════════════
                    // OFFSET ADVANCEMENT: Handle zero-width matches to prevent infinite loops
                    // ═══════════════════════════════════════════════════════════════════════════════

                    // Get the full match (capture group 0) to determine advancement
                    let full_match = matches.get(0).unwrap();

                    if full_match.end() > full_match.start() {
                        // Normal case: match has width, advance to end of match
                        offset = full_match.end();
                    } else {
                        // Zero-width match (e.g., ^, $, word boundaries, lookaheads)
                        // Advance by 1 to avoid infinite loop while still allowing
                        // subsequent matches at the next character
                        offset = full_match.end() + 1;
                    }
                } else {
                    // No more matches found for this rule from current offset
                    break;
                }
            }
        }

        // ═══════════════════════════════════════════════════════════════════════════════
        // FAST PATH: No matches found - output line unchanged to avoid processing
        // ═══════════════════════════════════════════════════════════════════════════════

        if style_ranges.is_empty() {
            writeln!(writer, "{}", line)?;
            continue;
        }

        // ═══════════════════════════════════════════════════════════════════════════════
        // PHASE 2B: STYLE APPLICATION - Build per-character style mapping
        // ═══════════════════════════════════════════════════════════════════════════════

        // Create per-character style array (one style reference per character)
        // Initialize all characters to default style (unstyled)
        let mut char_styles: Vec<&Style> = vec![&default_style; line.len()];

        // Apply all collected style ranges to the character array
        // Later ranges override earlier ones (simple precedence rule)
        for (start, end, style) in style_ranges {
            // Bounds check: ensure we don't exceed line length
            for item in char_styles.iter_mut().take(end.min(line.len())).skip(start) {
                *item = style;
            }
        }

        // ═══════════════════════════════════════════════════════════════════════════════
        // PHASE 2C: OUTPUT GENERATION - Write styled text with run-length encoding
        // ═══════════════════════════════════════════════════════════════════════════════

        // Run-length encoding: merge consecutive characters with same style
        // This minimizes ANSI escape sequence overhead
        let mut prev_style = &default_style;
        let mut offset = 0;

        // Scan through characters and detect style boundaries
        for i in 0..line.len() {
            let this_style = char_styles[i];

            // Style boundary detected - output previous styled segment
            if this_style != prev_style {
                if i > 0 {
                    // Apply previous style to characters from offset to current position
                    // Style::apply_to() generates appropriate ANSI escape codes
                    write!(writer, "{}", prev_style.apply_to(&line[offset..i]))?;
                }

                // Update tracking for next segment
                prev_style = this_style;
                offset = i;
            }
        }

        // Output the final segment (from last boundary to end of line)
        if offset < line.len() {
            write!(writer, "{}", prev_style.apply_to(&line[offset..]))?;
        }

        // Always terminate line with newline (matches input format)
        writeln!(writer)?;
    }

    #[cfg(feature = "debug")]
    if let Some(s) = overall_start.filter(|_| record_time) {
        eprintln!(
            "[rgrc:time] colorizer total processed {} lines in {:?}",
            lines_processed,
            s.elapsed()
        );
    }

    Ok(())
}