Skip to content

Commit cdbc4e4

Browse files
author
Paulo Remoli
committed
Support for multiline log messages
1 parent 2a4ebc7 commit cdbc4e4

5 files changed

Lines changed: 279 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to logana will be documented in this file.
77
### Added
88
- Mouse support
99
- Support for compressed and archive files: `.gz`, `.bz2`, `.xz`, `.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz`/`.txz`.
10+
- Support for multiline log message, previously each line was treated as separate entry.
1011

1112
### Fixed
1213
- Show an error in the notification bar on startup when the config file exists but cannot be read or parsed

src/ui/loading.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,16 @@ impl App {
10701070
tab.filter.match_counts = counts;
10711071
}
10721072
if is_last {
1073+
// Apply continuation-line grouping: continuation lines
1074+
// (those whose parser returned None) inherit their parent's
1075+
// filter visibility so they are hidden when the parent is
1076+
// hidden (e.g. by a date or exclude filter).
1077+
if let Some(cmap) = tab.continuation_map.clone() {
1078+
super::apply_continuation_correction(
1079+
&mut tab.filter.visible_indices,
1080+
&cmap,
1081+
);
1082+
}
10731083
if let Some(idx) = scroll_anchor
10741084
&& let Some(pos) = tab.filter.visible_indices.position_of(idx)
10751085
{

src/ui/tab_state/mod.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,14 @@ impl VisibleLines {
306306
(0..len).map(move |i| self.get(i))
307307
}
308308

309+
/// Returns `true` if file-line `idx` is in the visible set.
310+
pub fn contains(&self, idx: usize) -> bool {
311+
match self {
312+
Self::All(n) => idx < *n,
313+
Self::Filtered(v) => v.binary_search(&idx).is_ok(),
314+
}
315+
}
316+
309317
/// Binary search for file-line index `target`.
310318
/// Returns `Ok(pos)` if found, `Err(insert_pos)` otherwise.
311319
pub fn binary_search(&self, target: usize) -> Result<usize, usize> {
@@ -394,6 +402,57 @@ pub fn display_text_for_line(
394402
String::from_utf8_lossy(bytes).into_owned()
395403
}
396404

405+
/// Build a map from each line index to its "parent" line index — the most
406+
/// recent preceding line (inclusive) that the parser recognised as a log
407+
/// entry start. Continuation lines (e.g. stack-trace frames that have no
408+
/// timestamp/level) map to their nearest preceding parent; parent lines map
409+
/// to themselves.
410+
///
411+
/// Empty lines are never considered a new parent (they are already hidden by
412+
/// `compute_unfiltered_visible` when a format is active).
413+
pub fn build_continuation_map(reader: &FileReader, parser: &dyn LogFormatParser) -> Vec<usize> {
414+
let count = reader.line_count();
415+
let mut map = Vec::with_capacity(count);
416+
let mut last_parent = 0usize;
417+
for i in 0..count {
418+
let line = reader.get_line(i);
419+
if !line.is_empty() && parser.parse_line(line).is_some() {
420+
last_parent = i;
421+
}
422+
map.push(last_parent);
423+
}
424+
map
425+
}
426+
427+
/// Rewrite `visible` so that every continuation line (one whose parent differs
428+
/// from itself in `cmap`) is visible iff its parent is visible.
429+
///
430+
/// Parent lines are unaffected — their filter decision is preserved as-is.
431+
/// The output remains sorted because we iterate `0..n` in ascending order.
432+
pub fn apply_continuation_correction(visible: &mut VisibleLines, cmap: &[usize]) {
433+
let indices = match visible {
434+
VisibleLines::All(_) => return, // all lines visible — nothing to fix
435+
VisibleLines::Filtered(v) => v,
436+
};
437+
let n = cmap.len();
438+
// Build a flat boolean lookup: was each line kept by the filter?
439+
let mut filter_visible = vec![false; n];
440+
for &idx in indices.iter() {
441+
if idx < n {
442+
filter_visible[idx] = true;
443+
}
444+
}
445+
// Rebuild the list: each line is visible iff its parent is visible.
446+
indices.clear();
447+
for i in 0..n {
448+
let parent = cmap[i];
449+
if filter_visible[parent] {
450+
indices.push(i);
451+
}
452+
}
453+
// The result is already sorted (we iterate 0..n in order).
454+
}
455+
397456
pub struct TabState {
398457
pub file_reader: FileReader,
399458
pub log_manager: LogManager,
@@ -411,6 +470,10 @@ pub struct TabState {
411470
/// Some(fraction 0.0–1.0) while this tab's content is being extracted from an archive.
412471
/// None when waiting for its turn, or after extraction completes.
413472
pub extraction_progress: Option<f64>,
473+
/// Maps each line index to the nearest preceding line index (inclusive)
474+
/// that the log-format parser recognised as an entry start. `None` when
475+
/// no format has been detected or raw-mode is active.
476+
pub continuation_map: Option<Arc<Vec<usize>>>,
414477
}
415478

416479
impl TabState {
@@ -428,6 +491,10 @@ impl TabState {
428491
.unwrap_or_default();
429492
let fields_hidden_by_default = !default_hidden.is_empty();
430493

494+
let continuation_map = detected_format
495+
.as_deref()
496+
.map(|p| Arc::new(build_continuation_map(&file_reader, p)));
497+
431498
let mut tab = TabState {
432499
file_reader,
433500
log_manager,
@@ -466,6 +533,7 @@ impl TabState {
466533
load_state: None,
467534
archive_temp: None,
468535
extraction_progress: None,
536+
continuation_map,
469537
};
470538
tab.refresh_visible();
471539
tab
@@ -1476,6 +1544,22 @@ impl TabState {
14761544
return;
14771545
}
14781546

1547+
// Extend the continuation map for the newly-appended lines.
1548+
if let (Some(cmap), Some(parser)) = (
1549+
self.continuation_map.as_mut(),
1550+
self.display.format.as_deref().filter(|_| !self.display.raw_mode),
1551+
) {
1552+
let map = Arc::make_mut(cmap);
1553+
let mut last_parent = map.last().copied().unwrap_or(0);
1554+
for i in old_line_count..new_count {
1555+
let line = self.file_reader.get_line(i);
1556+
if !line.is_empty() && parser.parse_line(line).is_some() {
1557+
last_parent = i;
1558+
}
1559+
map.push(last_parent);
1560+
}
1561+
}
1562+
14791563
let has_active_filters =
14801564
self.filter.show_marks_only || self.log_manager.get_filters().iter().any(|f| f.enabled);
14811565

@@ -1613,6 +1697,25 @@ impl TabState {
16131697
}
16141698
}
16151699

1700+
// Apply continuation semantics: continuation lines inherit their parent's
1701+
// filter decision. A parent in this batch uses `new_visible`; a parent
1702+
// from earlier uses `visible_indices`.
1703+
if let Some(cmap) = self.continuation_map.clone() {
1704+
let existing = &self.filter.visible_indices;
1705+
let new_vis_set: std::collections::HashSet<usize> =
1706+
new_visible.iter().copied().collect();
1707+
new_visible.retain(|&i| {
1708+
let parent = cmap.get(i).copied().unwrap_or(i);
1709+
if parent == i {
1710+
true
1711+
} else if parent >= old_line_count {
1712+
new_vis_set.contains(&parent)
1713+
} else {
1714+
existing.contains(parent)
1715+
}
1716+
});
1717+
}
1718+
16161719
match &mut self.filter.visible_indices {
16171720
VisibleLines::All(n) => {
16181721
*n = new_count;
@@ -1847,6 +1950,11 @@ impl TabState {
18471950
}
18481951
}
18491952
self.display.format = fmt;
1953+
self.continuation_map = self
1954+
.display
1955+
.format
1956+
.as_deref()
1957+
.map(|p| Arc::new(build_continuation_map(&self.file_reader, p)));
18501958
}
18511959
}
18521960

src/ui/widgets/log_panel.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,36 @@ pub fn prepare_log_panel(
606606
let level = cached
607607
.and_then(|c| c.level.as_deref())
608608
.map(LogLevel::parse_level)
609-
.unwrap_or_else(|| LogLevel::detect_from_bytes(line_bytes));
609+
.unwrap_or_else(|| {
610+
// For continuation lines (parse_line returned None), inherit
611+
// the parent entry's level so the whole multiline block gets
612+
// the same color (e.g. a stack trace stays red under ERROR).
613+
if let Some(cmap) = &tab.continuation_map {
614+
let parent = cmap.get(line_idx).copied().unwrap_or(line_idx);
615+
if parent != line_idx {
616+
// Try the parent's cache entry first.
617+
if let Some(lvl) = tab
618+
.cache
619+
.parse
620+
.get(&parent)
621+
.filter(|(g, _)| *g == parse_gen)
622+
.and_then(|(_, c)| c.level.as_deref())
623+
{
624+
return LogLevel::parse_level(lvl);
625+
}
626+
// Parent not cached (outside viewport) — parse just
627+
// the level from its raw bytes without full layout.
628+
if let Some(parser) = tab.display.format.as_deref() {
629+
if let Some(parts) = parser.parse_line(tab.file_reader.get_line(parent)) {
630+
if let Some(lvl) = parts.level {
631+
return LogLevel::parse_level(lvl);
632+
}
633+
}
634+
}
635+
}
636+
}
637+
LogLevel::detect_from_bytes(line_bytes)
638+
});
610639
match level {
611640
LogLevel::Trace if !level_colors_disabled.contains("trace") => {
612641
base_style = base_style.fg(theme.trace_fg)

tests/integration.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,3 +616,133 @@ fn test_detect_format_does_not_select_dlt_for_non_dlt() {
616616
let parser = detect_format(&lines).unwrap();
617617
assert_ne!(parser.name(), "dlt");
618618
}
619+
620+
// ---------------------------------------------------------------------------
621+
// Multiline / continuation-line tests
622+
// ---------------------------------------------------------------------------
623+
624+
/// Build a FileReader from a multiline log string with Java-style stack traces.
625+
fn make_multiline_log() -> FileReader {
626+
// Line 0: ERROR entry with timestamp (parent)
627+
// Line 1: stack frame (continuation)
628+
// Line 2: stack frame (continuation)
629+
// Line 3: INFO entry with timestamp (standalone parent)
630+
let data = b"\
631+
2024-01-15 10:00:00 ERROR com.example.App - NullPointerException\n\
632+
at com.example.Foo.bar(Foo.java:42)\n\
633+
at com.example.Main.main(Main.java:10)\n\
634+
2024-01-16 11:00:00 INFO com.example.App - Application started\n";
635+
FileReader::from_bytes(data.to_vec())
636+
}
637+
638+
#[test]
639+
fn test_build_continuation_map_basic() {
640+
use logana::parser::detect_format;
641+
use logana::ui::build_continuation_map;
642+
643+
let reader = make_multiline_log();
644+
let sample: Vec<&[u8]> = (0..reader.line_count()).map(|i| reader.get_line(i)).collect();
645+
let parser = detect_format(&sample).expect("format should be detected");
646+
647+
let cmap = build_continuation_map(&reader, parser.as_ref());
648+
649+
assert_eq!(cmap.len(), 4);
650+
assert_eq!(cmap[0], 0, "line 0 is its own parent");
651+
assert_eq!(cmap[1], 0, "line 1 is a continuation of line 0");
652+
assert_eq!(cmap[2], 0, "line 2 is a continuation of line 0");
653+
assert_eq!(cmap[3], 3, "line 3 is its own parent");
654+
}
655+
656+
#[test]
657+
fn test_apply_continuation_correction_hides_orphaned_continuations() {
658+
use logana::ui::{VisibleLines, apply_continuation_correction};
659+
660+
// Simulate: filter kept only line 3 (INFO); lines 0, 1, 2 were filtered.
661+
// Without correction, continuation lines 1 and 2 might still be visible.
662+
// With correction they must be hidden because their parent (line 0) is hidden.
663+
let cmap = vec![0usize, 0, 0, 3]; // lines 1,2 → parent 0; line 3 → parent 3
664+
let mut visible = VisibleLines::Filtered(vec![1, 2, 3]); // lines 1 & 2 erroneously visible
665+
apply_continuation_correction(&mut visible, &cmap);
666+
// line 0 was NOT in visible, so its continuations (1, 2) must be removed.
667+
// line 3 is its own parent and was visible → stays.
668+
assert_eq!(visible, VisibleLines::Filtered(vec![3]));
669+
}
670+
671+
#[test]
672+
fn test_apply_continuation_correction_keeps_continuations_with_parent() {
673+
use logana::ui::{VisibleLines, apply_continuation_correction};
674+
675+
// Simulate: filter kept line 0 (ERROR parent); continuations 1 and 2 also passed.
676+
let cmap = vec![0usize, 0, 0, 3];
677+
let mut visible = VisibleLines::Filtered(vec![0, 1, 2, 3]);
678+
apply_continuation_correction(&mut visible, &cmap);
679+
// Parent 0 is visible → continuations 1 and 2 stay visible.
680+
assert_eq!(visible, VisibleLines::Filtered(vec![0, 1, 2, 3]));
681+
}
682+
683+
#[test]
684+
fn test_apply_continuation_correction_noop_for_all_variant() {
685+
use logana::ui::{VisibleLines, apply_continuation_correction};
686+
687+
let cmap = vec![0usize, 0, 1];
688+
let mut visible = VisibleLines::All(3);
689+
apply_continuation_correction(&mut visible, &cmap);
690+
// All-variant must remain unchanged.
691+
assert_eq!(visible, VisibleLines::All(3));
692+
}
693+
694+
#[tokio::test]
695+
async fn test_exclude_filter_hides_continuation_lines() {
696+
use logana::filters::FilterType;
697+
use logana::parser::detect_format;
698+
use logana::ui::{VisibleLines, apply_continuation_correction, build_continuation_map};
699+
700+
let (_db, mut manager) = setup().await;
701+
let reader = make_multiline_log();
702+
let sample: Vec<&[u8]> = (0..reader.line_count()).map(|i| reader.get_line(i)).collect();
703+
let parser = detect_format(&sample).expect("format detected");
704+
let cmap = build_continuation_map(&reader, parser.as_ref());
705+
706+
// Exclude lines containing "ERROR" — should hide line 0 AND its continuations
707+
manager
708+
.add_filter_with_color("ERROR".into(), FilterType::Exclude, None, None, true)
709+
.await;
710+
let (fm, _, _, _) = manager.build_filter_manager();
711+
let mut visible = VisibleLines::Filtered(fm.compute_visible(&reader));
712+
apply_continuation_correction(&mut visible, &cmap);
713+
714+
// Line 0 (ERROR) excluded; lines 1 & 2 are its continuations → also excluded.
715+
// Line 3 (INFO) should be visible.
716+
assert!(!visible.contains(0), "ERROR entry should be hidden");
717+
assert!(!visible.contains(1), "continuation 1 should be hidden with parent");
718+
assert!(!visible.contains(2), "continuation 2 should be hidden with parent");
719+
assert!(visible.contains(3), "INFO entry should be visible");
720+
}
721+
722+
#[tokio::test]
723+
async fn test_include_filter_shows_continuations_with_parent() {
724+
use logana::filters::FilterType;
725+
use logana::parser::detect_format;
726+
use logana::ui::{VisibleLines, apply_continuation_correction, build_continuation_map};
727+
728+
let (_db, mut manager) = setup().await;
729+
let reader = make_multiline_log();
730+
let sample: Vec<&[u8]> = (0..reader.line_count()).map(|i| reader.get_line(i)).collect();
731+
let parser = detect_format(&sample).expect("format detected");
732+
let cmap = build_continuation_map(&reader, parser.as_ref());
733+
734+
// Include only lines containing "ERROR" — parent matches; continuations should follow.
735+
manager
736+
.add_filter_with_color("ERROR".into(), FilterType::Include, None, None, true)
737+
.await;
738+
let (fm, _, _, _) = manager.build_filter_manager();
739+
let mut visible = VisibleLines::Filtered(fm.compute_visible(&reader));
740+
apply_continuation_correction(&mut visible, &cmap);
741+
742+
// Line 0 matches; its continuations (1, 2) should be shown.
743+
// Line 3 (INFO) does not match include → hidden.
744+
assert!(visible.contains(0), "ERROR entry should be visible");
745+
assert!(visible.contains(1), "continuation 1 should follow its visible parent");
746+
assert!(visible.contains(2), "continuation 2 should follow its visible parent");
747+
assert!(!visible.contains(3), "INFO entry should be hidden (no match)");
748+
}

0 commit comments

Comments
 (0)