diff --git a/Cargo.lock b/Cargo.lock index 358fecd01..bd609bee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,7 +1129,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hl" -version = "0.34.0" +version = "0.35.0-alpha.1" dependencies = [ "anstream", "anyhow", @@ -1180,6 +1180,7 @@ dependencies = [ "logos", "maplit", "memchr", + "mline", "mockall", "nonzero_ext", "notify", @@ -1531,6 +1532,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mline" +version = "0.1.0" +dependencies = [ + "heapopt", + "itertools 0.13.0", +] + [[package]] name = "mockall" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index b85e9f28a..cd68d8618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.34.0" +version = "0.35.0-alpha.1" edition = "2024" repository = "https://github.com/pamburus/hl" license = "MIT" @@ -76,6 +76,7 @@ known-folders = "1" log = "0.4" logos = "0.16" memchr = "2" +mline = { path = "./crate/mline" } nonzero_ext = "0.3" notify = { version = "8", features = ["macos_kqueue"] } num_cpus = "1" diff --git a/README.md b/README.md index 088c4d1e1..62e5e1c1e 100644 --- a/README.md +++ b/README.md @@ -794,6 +794,7 @@ Output Options: -E, --show-empty-fields Show empty fields, overrides --hide-empty-fields option [env: HL_SHOW_EMPTY_FIELDS=] --input-info Input number and filename layouts [default: auto] [possible values: auto, none, minimal, compact, full] --ascii [] Whether to restrict punctuation to ASCII characters only [env: HL_ASCII=] [default: auto] [possible values: auto, never, always] + -x, --expansion Whether to expand fields and messages [env: HL_EXPANSION=] [default: medium] [possible values: never, inline, low, medium, high, always] -o, --output Output file Input Options: diff --git a/benches/bench/ws/hl/combined.rs b/benches/bench/ws/hl/combined.rs index c87fd6d77..0989f45cd 100644 --- a/benches/bench/ws/hl/combined.rs +++ b/benches/bench/ws/hl/combined.rs @@ -12,25 +12,30 @@ use hl::{ DateTimeFormatter, Filter, LinuxDateFormat, Parser, ParserSettings, SegmentProcessor, Settings, Theme, app::{RecordIgnorer, SegmentProcess, SegmentProcessorOptions}, formatting::{NoOpRecordWithSourceFormatter, RecordFormatterBuilder}, - settings, + settings::{self, ExpansionMode}, timezone::Tz, }; const GROUP: &str = strcat!(super::GROUP, ND, "combined"); const THEME: &str = "universal"; -const SAMPLES: [(&str, &[u8]); 4] = [ - ("json", samples::log::elk01::JSON), - ("logfmt", samples::log::elk01::LOGFMT), - ("json", samples::log::int01::JSON), - ("logfmt", samples::log::int01::LOGFMT), +const SAMPLES: [(&str, &[u8], ExpansionMode); 6] = [ + ("json", samples::log::elk01::JSON, ExpansionMode::Inline), + ("logfmt", samples::log::elk01::LOGFMT, ExpansionMode::Inline), + ("json", samples::log::elk01::JSON, ExpansionMode::Always), + ("logfmt", samples::log::elk01::LOGFMT, ExpansionMode::Always), + ("json", samples::log::int01::JSON, ExpansionMode::Inline), + ("logfmt", samples::log::int01::LOGFMT, ExpansionMode::Inline), ]; pub(super) fn bench(c: &mut Criterion) { let mut c = c.benchmark_group(GROUP); - for (format, input) in SAMPLES { - let param = format!("{}:{}:{}", format, input.len(), hash(input)); + for (format, input, expansion) in SAMPLES { + let mut param = format!("{}:{}:{}", format, input.len(), hash(input)); + if expansion != ExpansionMode::Inline { + param = format!("{}:x={}", param, expansion); + } c.throughput(Throughput::Bytes(input.len() as u64)); @@ -43,6 +48,7 @@ pub(super) fn bench(c: &mut Criterion) { LinuxDateFormat::new("%b %d %T.%3N").compile(), Tz::FixedOffset(Utc.fix()), )) + .with_expansion(expansion.into()) .with_options(settings::Formatting::default()) .build(); diff --git a/crate/encstr/src/error.rs b/crate/encstr/src/error.rs index f5f90e731..343b7e039 100644 --- a/crate/encstr/src/error.rs +++ b/crate/encstr/src/error.rs @@ -25,5 +25,7 @@ impl fmt::Display for Error { } } +impl std::error::Error for Error {} + #[cfg(test)] mod tests; diff --git a/crate/heapopt/.gitignore b/crate/heapopt/.gitignore new file mode 100644 index 000000000..c41cc9e35 --- /dev/null +++ b/crate/heapopt/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/crate/heapopt/Cargo.toml b/crate/heapopt/Cargo.toml index 6f1feb79c..4c9652c07 100644 --- a/crate/heapopt/Cargo.toml +++ b/crate/heapopt/Cargo.toml @@ -2,6 +2,7 @@ name = "heapopt" version = "0.1.1" edition.workspace = true +description = "Optimized collections for minimal heap usage" repository.workspace = true license.workspace = true diff --git a/crate/heapopt/src/vec.rs b/crate/heapopt/src/vec.rs index d2b1eef56..b9e1fc25f 100644 --- a/crate/heapopt/src/vec.rs +++ b/crate/heapopt/src/vec.rs @@ -48,6 +48,17 @@ impl Vec { v } + /// Creates a new vector from the given slice. + #[inline] + #[allow(clippy::should_implement_trait)] + pub fn from_iter>(src: I) -> Self { + let mut iter = src.into_iter(); + Self { + head: heapless::Vec::from_iter(iter.by_ref().take(N)), + tail: std::vec::Vec::from_iter(iter), + } + } + /// Returns the number of elements in the vector. #[inline] pub fn len(&self) -> usize { diff --git a/crate/mline/.gitignore b/crate/mline/.gitignore new file mode 100644 index 000000000..c41cc9e35 --- /dev/null +++ b/crate/mline/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/crate/mline/Cargo.toml b/crate/mline/Cargo.toml new file mode 100644 index 000000000..fb654c98e --- /dev/null +++ b/crate/mline/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mline" +version = "0.1.0" +edition.workspace = true +description = "Multi-line string manipulation library for Rust" +repository.workspace = true +license.workspace = true +workspace = "../.." + +[dependencies] +heapopt = { path = "../heapopt" } +itertools = "0.13" diff --git a/crate/mline/src/lib.rs b/crate/mline/src/lib.rs new file mode 100644 index 000000000..49f89a5d2 --- /dev/null +++ b/crate/mline/src/lib.rs @@ -0,0 +1,367 @@ +// std imports +use std::ops::{Bound, Range, RangeBounds, RangeTo}; + +// --- + +pub fn prefix_lines(buf: &mut Vec, bytes: BR, lines: LR, prefix: &[u8]) +where + BR: RangeBounds, + LR: RangeBounds, +{ + let mut o = OffsetBuf::new(); + let (bytes, lines, offsets) = prepare_for_prefix(bytes, buf, &mut o, lines); + xfix_lines( + buf, + bytes, + lines.end - lines.start, + offsets, + prefix.len(), + |buf, range| { + buf[range].copy_from_slice(prefix); + }, + ); +} + +pub fn prefix_lines_within(buf: &mut Vec, bytes: BR, lines: LR, prefix: Range) +where + BR: RangeBounds, + LR: RangeBounds, +{ + if prefix.end < prefix.start { + panic!( + "prefix end ({1}) should be greater or equal than start ({0})", + prefix.start, prefix.end + ); + } + + let mut o = OffsetBuf::new(); + let (bytes, lines, offsets) = prepare_for_prefix(bytes, buf, &mut o, lines); + + if prefix.end > bytes.start { + panic!("prefix {prefix:?} should go before bytes range {bytes:?}"); + } + + xfix_lines( + buf, + bytes, + lines.end - lines.start, + offsets, + prefix.clone().count(), + |buf, range| { + debug_assert!(prefix.clone().count() == range.clone().count()); + buf.copy_within(prefix.clone(), range.start); + }, + ); +} + +pub fn suffix_lines(buf: &mut Vec, bytes: BR, lines: LR, suffix: &[u8]) +where + BR: RangeBounds, + LR: RangeBounds, +{ + let mut o = OffsetBuf::new(); + let (bytes, lines, offsets) = prepare_for_suffix(bytes, buf, &mut o, lines); + xfix_lines( + buf, + bytes, + lines.end - lines.start, + offsets, + suffix.len(), + |buf, range| { + buf[range].copy_from_slice(suffix); + }, + ); +} + +pub fn suffix_lines_within(buf: &mut Vec, bytes: BR, lines: LR, suffix: Range) +where + BR: RangeBounds, + LR: RangeBounds, +{ + if suffix.end < suffix.start { + panic!( + "suffix end ({1}) should be greater or equal than start ({0})", + suffix.start, suffix.end + ); + } + + let mut o = OffsetBuf::new(); + let (bytes, lines, offsets) = prepare_for_suffix(bytes, buf, &mut o, lines); + + if suffix.end > bytes.start { + panic!("suffix {suffix:?} should go before bytes range {bytes:?}"); + } + + xfix_lines( + buf, + bytes, + lines.end - lines.start, + offsets, + suffix.clone().count(), + |buf, range| { + debug_assert!(suffix.clone().count() == range.clone().count()); + buf.copy_within(suffix.clone(), range.start); + }, + ); +} + +// --- + +fn adjust_ranges(offsets: &OffsetBuf, bytes: Range, lines: R) -> (Range, Range) +where + R: RangeBounds, +{ + let os = match lines.start_bound() { + Bound::Included(&start) => start, + Bound::Excluded(start) => start + 1, + Bound::Unbounded => 0, + }; + + let oe = match lines.end_bound() { + Bound::Included(end) => end + 1, + Bound::Excluded(&end) => end, + Bound::Unbounded => offsets.len(), + }; + + let os = os.min(offsets.len()); + + let bs = if os == 0 { bytes.start } else { offsets[os - 1] }; + let be = if oe >= offsets.len() { bytes.end } else { offsets[oe] }; + + (bs..be, os..oe) +} + +fn prepare_for_prefix<'a, BR, LR>( + bytes: BR, + buf: &mut [u8], + o: &'a mut heapopt::Vec, + lines: LR, +) -> (Range, Range, impl Iterator + 'a) +where + BR: RangeBounds, + LR: RangeBounds, +{ + let bytes = range(bytes, ..buf.len()); + + o.push(bytes.start); + for i in bytes.clone() { + if buf[i] == b'\n' && i != bytes.end - 1 { + o.push(i + 1); + } + } + + let (bytes, lines) = adjust_ranges(o, bytes, lines); + let ol = o.len(); + let offsets = o.iter().copied().rev().take(ol - lines.start).skip(ol - lines.end); + (bytes, lines, offsets) +} + +fn prepare_for_suffix<'a, BR, LR>( + bytes: BR, + buf: &mut [u8], + o: &'a mut heapopt::Vec, + lines: LR, +) -> (Range, Range, impl Iterator + 'a) +where + BR: RangeBounds, + LR: RangeBounds, +{ + let bytes = range(bytes, ..buf.len()); + + for i in bytes.clone() { + if buf[i] == b'\n' { + o.push(i); + } + } + + let (bytes, lines) = adjust_ranges(o, bytes, lines); + let ol = o.len(); + let offsets = o.iter().copied().rev().take(ol - lines.start).skip(ol - lines.end); + (bytes, lines, offsets) +} + +fn xfix_lines(buf: &mut Vec, range: Range, n: usize, offsets: OI, xfl: usize, f: F) +where + OI: IntoIterator, + F: Fn(&mut [u8], Range), +{ + let m = buf.len(); + let xl = n * xfl; + let sl = m - range.end; + + buf.resize(m + xl, 0); + buf[range.end..].rotate_left(sl); + + let mut ks = range.end; + let mut ke = buf.len() - sl; + for o in offsets { + let l = ks - o; + ks -= l; + buf[ks..ke].rotate_left(l); + ke -= l; + f(buf, ke - xfl..ke); + ke -= xfl; + } +} + +#[must_use] +pub fn range(range: R, bounds: RangeTo) -> Range +where + R: RangeBounds, +{ + let len = bounds.end; + + let start = match range.start_bound() { + Bound::Included(&start) => start, + Bound::Unbounded => 0, + _ => panic!("range start must be inclusive or unbounded"), + }; + + let end = match range.end_bound() { + Bound::Included(end) => end + .checked_add(1) + .unwrap_or_else(|| panic!("attempted to index slice up to maximum usize")), + Bound::Excluded(&end) => end, + Bound::Unbounded => len, + }; + + if start > end { + panic!("slice index starts at {start} but ends at {end}"); + } + if end > len { + panic!("range end index {end} out of range for slice of length {len}"); + } + + Range { start, end } +} + +// --- + +type OffsetBuf = heapopt::Vec; + +const PREALLOC: usize = 128; + +// --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prefix_lines_1() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n".bytes().collect(); + prefix_lines(&mut buf, .., .., b"> "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "> abc\n> defg\n> hi\n> \n> jkl\n"); + } + + #[test] + fn test_prefix_lines_2() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl".bytes().collect(); + prefix_lines(&mut buf, .., .., b"> "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "> abc\n> defg\n> hi\n> \n> jkl"); + } + + #[test] + fn test_prefix_lines_3() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl".bytes().collect(); + prefix_lines(&mut buf, 4.., .., b"> "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "abc\n> defg\n> hi\n> \n> jkl"); + } + + #[test] + fn test_prefix_lines_4() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl".bytes().collect(); + prefix_lines(&mut buf, .., 1.., b"> "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "abc\n> defg\n> hi\n> \n> jkl"); + } + + #[test] + fn test_prefix_lines_within_1() { + let mut buf: Vec<_> = "> abc\ndefg\nhi\n\njkl".bytes().collect(); + prefix_lines_within(&mut buf, 2.., 1.., 0..2); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "> abc\n> defg\n> hi\n> \n> jkl"); + } + + #[test] + #[should_panic] + fn test_prefix_lines_within_2() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + prefix_lines_within(&mut buf, .., 1.., 18..20); + } + + #[test] + #[should_panic] + fn test_prefix_lines_within_3() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + #[allow(clippy::reversed_empty_ranges)] + prefix_lines_within(&mut buf, 15.., 1.., 3..2); + } + + #[test] + fn test_prefix_lines_within_4() { + let mut buf: Vec<_> = "> abc defg hi jkl".bytes().collect(); + prefix_lines_within(&mut buf, 2.., 1.., 0..2); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "> abc defg hi jkl"); + } + + #[test] + fn test_suffix_lines_1() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n".bytes().collect(); + suffix_lines(&mut buf, .., .., b": "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "abc: \ndefg: \nhi: \n: \njkl: \n"); + } + + #[test] + fn test_suffix_lines_2() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n".bytes().collect(); + suffix_lines(&mut buf, ..=11, .., b": "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "abc: \ndefg: \nhi: \n\njkl\n"); + } + + #[test] + fn test_suffix_lines_3() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n".bytes().collect(); + suffix_lines(&mut buf, .., 1..5, b": "); + assert_eq!(std::str::from_utf8(&buf).unwrap(), "abc\ndefg: \nhi: \n: \njkl: \n"); + } + + #[test] + fn test_suffix_lines_within_1() { + let mut buf: Vec<_> = "(!) - abc\ndefg\nhi\n\njkl".bytes().collect(); + suffix_lines_within(&mut buf, 4.., 1..=3, 0..4); + assert_eq!( + std::str::from_utf8(&buf).unwrap(), + "(!) - abc\ndefg(!) \nhi(!) \n(!) \njkl" + ); + } + + #[test] + #[should_panic] + fn test_suffix_lines_within_2() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + suffix_lines_within(&mut buf, .., 1.., 18..20); + } + + #[test] + #[should_panic] + fn test_suffix_lines_within_3() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + #[allow(clippy::reversed_empty_ranges)] + suffix_lines_within(&mut buf, 10.., 1.., 4..1); + } + + #[test] + #[should_panic] + fn test_suffix_lines_within_4() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + #[allow(clippy::reversed_empty_ranges)] + suffix_lines_within(&mut buf, 12..10, 1..3, 1..3); + } + + #[test] + #[should_panic] + fn test_suffix_lines_within_5() { + let mut buf: Vec<_> = "abc\ndefg\nhi\n\njkl\n> ".bytes().collect(); + suffix_lines_within(&mut buf, 10..40, 1..3, 1..3); + } +} diff --git a/etc/defaults/config.toml b/etc/defaults/config.toml index 703e80016..13be7e899 100644 --- a/etc/defaults/config.toml +++ b/etc/defaults/config.toml @@ -1,4 +1,4 @@ -#:schema https://raw.githubusercontent.com/pamburus/hl/v0.34.0/schema/json/config.schema.json +#:schema https://raw.githubusercontent.com/pamburus/hl/3df9bc6e91ba0e6fb8c3496840d8e9bfef464ab9/schema/json/config.schema.json # # Time format, see https://man7.org/linux/man-pages/man1/date.1.html for details. @@ -157,3 +157,58 @@ input-name-right-separator = { ascii = " | ", unicode = " │ " } input-name-clipping = { ascii = "..", unicode = "··" } input-name-common-part = { ascii = "..", unicode = "··" } message-delimiter = { ascii = "::", unicode = "›" } + +# Expansion settings. +[formatting.expansion] +# Field expansion mode [never|inline|low|medium|high|always]. +mode = "medium" + +# Field expansion setting profiles for all modes except [never,inline,always]. +[formatting.expansion.profiles] +# Field expansion settings for the low mode. +[formatting.expansion.profiles.low] +# Multiline field expansion mode [standard|disabled|inline]. +multiline = "standard" + +# Various complexity thresholds for field expansion. +[formatting.expansion.profiles.low.thresholds] +# Threshold of overall draft log record complexity that triggers expansion of all fields. +global = 4096 +# Threshold of cumulative complexity of all fields on the first line that triggers expansion of remaining fields. +cumulative = 512 +# Threshold of message field's complexity that triggers its expansion. +message = 256 +# Threshold of any other field's complexity that triggers its expansion. +field = 256 + +# Field expansion settings for the medium mode. +[formatting.expansion.profiles.medium] +# Multiline field expansion mode [standard|disabled|inline]. +multiline = "standard" + +# Various complexity thresholds for field expansion. +[formatting.expansion.profiles.medium.thresholds] +# Threshold of overall draft log record complexity that triggers expansion of all fields. +global = 2048 +# Threshold of cumulative complexity of all fields on the first line that triggers expansion of remaining fields. +cumulative = 256 +# Threshold of message field's complexity that triggers its expansion. +message = 192 +# Threshold of any other field's complexity that triggers its expansion. +field = 192 + +# Field expansion settings for the high mode. +[formatting.expansion.profiles.high] +# Multiline field expansion mode [standard|disabled|inline]. +multiline = "standard" + +# Various complexity thresholds for field expansion. +[formatting.expansion.profiles.high.thresholds] +# Threshold of overall draft log record complexity that triggers expansion of all fields. +global = 1024 +# Threshold of cumulative complexity of all fields on the first line that triggers expansion of remaining fields. +cumulative = 128 +# Threshold of message field's complexity that triggers its expansion. +message = 128 +# Threshold of any other field's complexity that triggers its expansion. +field = 128 diff --git a/etc/defaults/themes/@base.toml b/etc/defaults/themes/@base.toml index bbcc3f668..1015e71d9 100644 --- a/etc/defaults/themes/@base.toml +++ b/etc/defaults/themes/@base.toml @@ -1,4 +1,4 @@ -#:schema https://raw.githubusercontent.com/pamburus/hl/v0.34.0/schema/json/theme.schema.v1.json +#:schema https://raw.githubusercontent.com/pamburus/hl/3df9bc6e91ba0e6fb8c3496840d8e9bfef464ab9/schema/json/theme.schema.v1.json version = "1.0" tags = ["base", "dark", "light", "16color"] @@ -37,6 +37,8 @@ message-delimiter.style = "muted" field.style = "muted" key.style = "key" ellipsis.style = "muted" +bullet.style = "muted" +value-expansion.style = "muted" object.style = "syntax" array.style = "syntax" string.style = "value" diff --git a/schema/json/config.schema.json b/schema/json/config.schema.json index 71d3c16e3..c472981ab 100644 --- a/schema/json/config.schema.json +++ b/schema/json/config.schema.json @@ -26,6 +26,32 @@ "required": ["ascii", "unicode"] } ] + }, + "expansion-profile": { + "type": "object", + "properties": { + "multiline": { + "type": "string", + "enum": ["standard", "disabled", "inline"] + }, + "thresholds": { + "type": "object", + "properties": { + "global": { + "type": "integer" + }, + "cumulative": { + "type": "integer" + }, + "message": { + "type": "integer" + }, + "field": { + "type": "integer" + } + } + } + } } }, "properties": { @@ -253,6 +279,29 @@ } } }, + "expansion": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["never", "inline", "low", "medium", "high", "always"] + }, + "profiles": { + "type": "object", + "properties": { + "low": { + "$ref": "#/definitions/expansion-profile" + }, + "medium": { + "$ref": "#/definitions/expansion-profile" + }, + "high": { + "$ref": "#/definitions/expansion-profile" + } + } + } + } + }, "punctuation": { "type": "object", "additionalProperties": false, diff --git a/schema/json/theme.schema.v0.json b/schema/json/theme.schema.v0.json index 19862173d..0e81ecd40 100644 --- a/schema/json/theme.schema.v0.json +++ b/schema/json/theme.schema.v0.json @@ -130,6 +130,12 @@ }, "ellipsis": { "$ref": "#/$defs/style" + }, + "bullet": { + "$ref": "#/$defs/style" + }, + "value-expansion": { + "$ref": "#/$defs/style" } }, "required": [], diff --git a/schema/json/theme.schema.v1.json b/schema/json/theme.schema.v1.json index 3bde2d9ce..75e072bd0 100644 --- a/schema/json/theme.schema.v1.json +++ b/schema/json/theme.schema.v1.json @@ -242,6 +242,12 @@ }, "ellipsis": { "$ref": "#/$defs/style" + }, + "bullet": { + "$ref": "#/$defs/style" + }, + "value-expansion": { + "$ref": "#/$defs/style" } }, "required": [], diff --git a/src/app.rs b/src/app.rs index cc160e034..94ac99ca7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -35,15 +35,17 @@ use crate::{ datefmt::{DateTimeFormat, DateTimeFormatter}, error::*, filtering::{MatchOptions, NoNormalizing}, - fmtx::aligned_left, - formatting::{DynRecordWithSourceFormatter, RawRecordFormatter, RecordFormatterBuilder, RecordWithSourceFormatter}, + fmtx::{Adjustment, Alignment, Padding, aligned}, + formatting::{ + DynRecordWithSourceFormatter, Expansion, RawRecordFormatter, RecordFormatterBuilder, RecordWithSourceFormatter, + }, fsmon::{self, EventKind}, index::{Indexer, IndexerSettings, Timestamp}, input::{BlockLine, Input, InputHolder, InputReference}, model::{Filter, Parser, ParserSettings, RawRecord, Record, RecordFilter, RecordWithSourceConstructor}, query::Query, scanning::{BufFactory, Delimit, Delimiter, Scanner, SearchExt, Segment, SegmentBuf, SegmentBufFactory}, - settings::{AsciiMode, FieldShowOption, Fields, Formatting, InputInfo, ResolvedPunctuation}, + settings::{AsciiMode, ExpansionMode, FieldShowOption, Fields, Formatting, InputInfo, ResolvedPunctuation}, theme::{Element, StylingPush, SyncIndicatorPack, Theme}, timezone::Tz, vfs::LocalFileSystem, @@ -83,6 +85,7 @@ pub struct Options { pub unix_ts_unit: Option, pub flatten: bool, pub ascii: AsciiMode, + pub expand: ExpansionMode, } impl Options { @@ -120,6 +123,11 @@ impl Options { fn with_input_info(self, input_info: InputInfoSet) -> Self { Self { input_info, ..self } } + + #[cfg(test)] + fn with_expansion(self, expand: ExpansionMode) -> Self { + Self { expand, ..self } + } } pub type InputInfoSet = EnumSet; @@ -426,7 +434,7 @@ impl App { // spawn worker threads let mut workers = Vec::with_capacity(n); for (rxp, txw) in izip!(rxp, txw) { - workers.push(scope.spawn(closure!(ref parser, |_| -> Result<()> { + workers.push(scope.spawn(closure!(ref parser, ref input_badges, |_| -> Result<()> { let mut processor = self.new_segment_processor(parser); for (block, ts_min, i, j) in rxp.iter() { let mut buf = Vec::with_capacity(2 * usize::try_from(block.size())?); @@ -435,10 +443,11 @@ impl App { if line.is_empty() { continue; } + let prefix = input_badges.as_ref().map(|b| b[i].as_str()).unwrap_or(""); processor.process( line.bytes(), &mut buf, - "", + prefix, Some(1), &mut |record: &Record, location: Range| { if let Some(ts) = &record.ts { @@ -503,9 +512,6 @@ impl App { if tso >= tsi && !done { continue; } - if let Some(badges) = &input_badges { - output.write_all(badges[item.2].as_bytes())?; - } output.write_all((item.0).1.bytes())?; output.write_all(b"\n")?; match item.1.next() { @@ -551,6 +557,7 @@ impl App { let parser = self.parser(); let sfi = Arc::new(SegmentBufFactory::new(self.options.buffer_size.into())); let bfo = BufFactory::new(self.options.buffer_size.into()); + thread::scope(|scope| -> Result<()> { // prepare receive/transmit channels for input data let (txi, rxi) = channel::bounded(1); @@ -819,10 +826,20 @@ impl App { s.batch(|buf| buf.extend(opt.input_number_left_separator.as_bytes())); s.element(Element::InputNumberInner, |s| { s.batch(|buf| { - aligned_left(buf, num_width + 1, b' ', |mut buf| { - buf.extend_from_slice(opt.input_number_prefix.as_bytes()); - buf.extend_from_slice(format!("{}", i).as_bytes()); - }); + aligned( + buf, + Some(Adjustment { + alignment: Alignment::Right, + padding: Padding { + pad: b' ', + width: num_width + 1, + }, + }), + |mut buf| { + buf.extend_from_slice(opt.input_number_prefix.as_bytes()); + buf.extend_from_slice(format!("{}", i).as_bytes()); + }, + ); buf.extend(opt.input_name_left_separator.as_bytes()); }); }); @@ -884,9 +901,11 @@ impl App { .with_raw_fields(options.raw_fields) .with_flatten(options.flatten) .with_ascii(options.ascii) + .with_expansion(Expansion::from(options.formatting.expansion.clone()).with_mode(options.expand)) .with_always_show_time(options.fields.settings.predefined.time.show == FieldShowOption::Always) .with_always_show_level(options.fields.settings.predefined.level.show == FieldShowOption::Always) .with_punctuation(punctuation) + .with_expansion(Expansion::from(options.formatting.expansion.clone()).with_mode(options.expand)) .build(), ) } @@ -998,7 +1017,9 @@ impl<'a, Formatter: RecordWithSourceFormatter, Filter: RecordFilter> SegmentProc if ar.prefix.last().map(|&x| x == b' ') == Some(false) { buf.push(b' '); } - self.formatter.format_record(buf, record.with_source(&line[ar.offsets])); + let prefix_range = begin..buf.len(); + self.formatter + .format_record(buf, prefix_range, record.with_source(&line[ar.offsets])); let end = buf.len(); observer.observe_record(&record, begin..end); produced_some = true; diff --git a/src/app/tests.rs b/src/app/tests.rs index b549a0f98..34f3db096 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -16,9 +16,15 @@ use crate::{ level::{InfallibleLevel, Level}, model::FieldFilterSet, scanning::{BufFactory, PartialPlacement, Segment, SegmentBuf, SegmentBufFactory}, - settings::{self, AsciiMode, DisplayVariant, MessageFormat, MessageFormatting}, + settings::{self, AsciiMode, DisplayVariant, ExpansionMode, MessageFormat, MessageFormatting}, + syntax::*, + themecfg, }; +fn theme() -> Arc { + Sample::sample() +} + #[test] fn test_common_prefix_len() { let items = vec!["abc", "abcd", "ab", "ab"]; @@ -418,25 +424,27 @@ fn test_issue_176_complex_span_json() { )); let mut output = Vec::new(); let app = App::new( - options().with_fields(FieldOptions { - settings: Fields { - predefined: settings::PredefinedFields { - logger: settings::Field { - names: vec!["span.name".to_string()], - show: FieldShowOption::Always, - } - .into(), - caller: settings::Field { - names: vec!["span.source".to_string()], - show: FieldShowOption::Always, - } - .into(), + options() + .with_expansion(ExpansionMode::Never) + .with_fields(FieldOptions { + settings: Fields { + predefined: settings::PredefinedFields { + logger: settings::Field { + names: vec!["span.name".to_string()], + show: FieldShowOption::Always, + } + .into(), + caller: settings::Field { + names: vec!["span.source".to_string()], + show: FieldShowOption::Always, + } + .into(), + ..Default::default() + }, ..Default::default() }, ..Default::default() - }, - ..Default::default() - }), + }), ); app.run(vec![input], &mut output).unwrap(); assert_eq!( @@ -564,6 +572,7 @@ fn options() -> Options { unix_ts_unit: None, flatten: false, ascii: AsciiMode::Off, + expand: Default::default(), } } @@ -593,12 +602,12 @@ fn test_ascii_mode_handling() { // Test ASCII mode let mut buf_ascii = Vec::new(); - formatter_ascii.format_record(&mut buf_ascii, &record); + formatter_ascii.format_record(&mut buf_ascii, 0..0, &record); let result_ascii = String::from_utf8(buf_ascii).unwrap(); // Test Unicode mode let mut buf_utf8 = Vec::new(); - formatter_utf8.format_record(&mut buf_utf8, &record); + formatter_utf8.format_record(&mut buf_utf8, 0..0, &record); let result_utf8 = String::from_utf8(buf_utf8).unwrap(); // Verify that the ASCII mode uses ASCII arrows @@ -685,8 +694,237 @@ fn test_input_badges_with_ascii_mode() { assert_ne!(badges_a, badges_u, "ASCII and Unicode badges should be different"); } -fn theme() -> Arc { - Sample::sample() +#[test] +fn test_expand_always() { + let input = input(concat!( + r#"level=debug time=2024-01-25T19:10:20.435369+01:00 msg=m1 a.b.c=10 a.b.d=20 a.c.b=11 caller=src1"#, + "\n", + )); + + let mut output = Vec::new(); + let app = App::new(Options { + expand: ExpansionMode::Always, + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + assert_eq!( + std::str::from_utf8(&output).unwrap(), + format!( + concat!( + "2024-01-25 18:10:20.435 |{ld}| m1 @ src1\n", + " |{lx}| > a.b.c=10\n", + " |{lx}| > a.b.d=20\n", + " |{lx}| > a.c.b=11\n" + ), + ld = LEVEL_DEBUG, + lx = LEVEL_EXPANDED, + ), + ); +} + +#[test] +fn test_expand_never() { + let input = input(concat!( + r#"level=debug time=2024-01-25T19:10:20.435369+01:00 msg="some long long long long message" caller=src1 a=long-long-long-value-1 b=long-long-long-value-2 c=long-long-long-value-3 d=long-long-long-value-4 e=long-long-long-value-5 f=long-long-long-value-6 g=long-long-long-value-7 h=long-long-long-value-8 i=long-long-long-value-9 j=long-long-long-value-10 k=long-long-long-value-11 l=long-long-long-value-12 m=long-long-long-value-13 n=long-long-long-value-14 o=long-long-long-value-15 p=long-long-long-value-16 q=long-long-long-value-17 r=long-long-long-value-18 s=long-long-long-value-19 t=long-long-long-value-20 u=long-long-long-value-21 v=long-long-long-value-22 w=long-long-long-value-23 x=long-long-long-value-24 w=long-long-long-value-26 z=long-long-long-value-26"#, + "\n", + )); + + let mut output = Vec::new(); + let app = App::new(Options { + expand: ExpansionMode::Never, + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + assert_eq!( + std::str::from_utf8(&output).unwrap(), + "2024-01-25 18:10:20.435 |DBG| some long long long long message a=long-long-long-value-1 b=long-long-long-value-2 c=long-long-long-value-3 d=long-long-long-value-4 e=long-long-long-value-5 f=long-long-long-value-6 g=long-long-long-value-7 h=long-long-long-value-8 i=long-long-long-value-9 j=long-long-long-value-10 k=long-long-long-value-11 l=long-long-long-value-12 m=long-long-long-value-13 n=long-long-long-value-14 o=long-long-long-value-15 p=long-long-long-value-16 q=long-long-long-value-17 r=long-long-long-value-18 s=long-long-long-value-19 t=long-long-long-value-20 u=long-long-long-value-21 v=long-long-long-value-22 w=long-long-long-value-23 x=long-long-long-value-24 w=long-long-long-value-26 z=long-long-long-value-26 @ src1\n", + ); +} + +#[test] +fn test_expand_value_with_time() { + let input = input(concat!( + r#"level=debug time=2024-01-25T19:10:20.435369+01:00 msg=hello caller=src1 a="line one\nline two\nline three\n""#, + "\n", + )); + + let mut output = Vec::new(); + let app = App::new(Options { + expand: ExpansionMode::Always, + theme: Theme::from(themecfg::Theme { + elements: themecfg::StylePack::new(hashmap! { + Element::ValueExpansion => themecfg::Style::default(), + }), + ..Default::default() + }) + .into(), + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + + let actual = std::str::from_utf8(&output).unwrap(); + let expected = format!( + concat!( + "2024-01-25 18:10:20.435 |{ld}| hello @ src1\n", + " |{lx}| > a={vh}\n", + " |{lx}| {vi}line one\n", + " |{lx}| {vi}line two\n", + " |{lx}| {vi}line three\n", + ), + ld = LEVEL_DEBUG, + lx = LEVEL_EXPANDED, + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ); + + assert_eq!(actual, expected, "\nactual:\n{}expected:\n{}", actual, expected); +} + +#[test] +fn test_expand_value_without_time() { + let input = input(concat!( + r#"level=debug msg=hello caller=src1 a="line one\nline two\nline three\n""#, + "\n", + )); + + let mut output = Vec::new(); + let app = App::new(Options { + expand: ExpansionMode::Always, + theme: Theme::from(themecfg::Theme { + elements: themecfg::StylePack::new(hashmap! { + Element::ValueExpansion => themecfg::Style::default(), + }), + ..Default::default() + }) + .into(), + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + + let actual = std::str::from_utf8(&output).unwrap(); + let expected = format!( + concat!( + "|{ld}| hello @ src1\n", + "|{lx}| > a={vh}\n", + "|{lx}| {vi}line one\n", + "|{lx}| {vi}line two\n", + "|{lx}| {vi}line three\n", + ), + ld = LEVEL_DEBUG, + lx = LEVEL_EXPANDED, + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ); + + assert_eq!(actual, expected, "\nactual:\n{}expected:\n{}", actual, expected); +} + +#[test] +fn test_expand_empty_values() { + let input = input(concat!(r#"level=debug msg=hello caller=src1 a="" b="" c="""#, "\n",)); + + let mut output = Vec::new(); + let app = App::new(options()); + + app.run(vec![input], &mut output).unwrap(); + + assert_eq!( + std::str::from_utf8(&output).unwrap(), + concat!(r#"|DBG| hello a="" b="" c="" @ src1"#, "\n") + ); +} + +#[test] +fn test_expand_empty_hidden_values() { + let input = input(concat!(r#"level=debug msg=hello caller=src1 a="" b="" c="""#, "\n",)); + + let mut output = Vec::new(); + let app = App::new(Options { + hide_empty_fields: true, + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + + assert_eq!( + std::str::from_utf8(&output).unwrap(), + concat!(r#"|DBG| hello @ src1"#, "\n") + ); +} + +#[test] +fn test_expand_unparseable_timestamp() { + let input = input(concat!( + r#"level=debug time=invalid-timestamp msg=hello caller=src1 a="line one\nline two\nline three\n""#, + "\n", + )); + + let mut output = Vec::new(); + let app = App::new(Options { + expand: ExpansionMode::Always, + theme: Theme::from(themecfg::Theme { + elements: themecfg::StylePack::new(hashmap! { + Element::ValueExpansion => themecfg::Style::default(), + }), + ..Default::default() + }) + .into(), + ..options() + }); + + app.run(vec![input], &mut output).unwrap(); + + let actual = std::str::from_utf8(&output).unwrap(); + let expected = format!( + concat!( + "|{ld}| hello @ src1\n", + "|{lx}| > ts=invalid-timestamp\n", + "|{lx}| > a={vh}\n", + "|{lx}| {vi}line one\n", + "|{lx}| {vi}line two\n", + "|{lx}| {vi}line three\n", + ), + ld = LEVEL_DEBUG, + lx = LEVEL_EXPANDED, + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ); + + assert_eq!(actual, expected, "\nactual:\n{}expected:\n{}", actual, expected); +} + +#[test] +fn test_input_badges() { + let inputs = (1..12).map(|i| input(format!("msg=hello input={}\n", i))).collect_vec(); + + let app = App::new(Options { + input_info: InputInfo::Minimal.into(), + ..options() + }); + + let mut output = Vec::new(); + app.run(inputs, &mut output).unwrap(); + + assert_eq!( + std::str::from_utf8(&output).unwrap(), + concat!( + " #0 | hello input=1\n", + " #1 | hello input=2\n", + " #2 | hello input=3\n", + " #3 | hello input=4\n", + " #4 | hello input=5\n", + " #5 | hello input=6\n", + " #6 | hello input=7\n", + " #7 | hello input=8\n", + " #8 | hello input=9\n", + " #9 | hello input=10\n", + "#10 | hello input=11\n", + ) + ); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 2d490aaeb..966194724 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,7 +16,7 @@ use crate::{ config, error::*, level::{LevelValueParser, RelaxedLevel}, - settings::{self, AsciiModeOpt, InputInfo}, + settings::{self, AsciiModeOpt, ExpansionMode, InputInfo}, themecfg, }; use enumset_ext::convert::str::EnumSet; @@ -371,6 +371,24 @@ pub struct Opt { )] pub ascii: AsciiOption, + /// Whether to expand fields and messages + /// + /// Controls how large field values and messages are formatted. + /// Higher expansion levels will break up long content into multiple lines. + #[arg( + long, + short = 'x', + env = "HL_EXPANSION", + value_name = "MODE", + value_enum, + default_value_t = ExpansionOption::from(config::global::get().formatting.expansion.mode), + default_missing_value = "always", + num_args = 0..=1, + overrides_with = "expansion", + help_heading = heading::OUTPUT + )] + pub expansion: ExpansionOption, + /// Output file #[arg(long, short = 'o', overrides_with = "output", value_name = "FILE", help_heading = heading::OUTPUT)] pub output: Option, @@ -518,12 +536,37 @@ pub enum UnixTimestampUnit { Ns, } -#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum FlattenOption { Never, + #[default] Always, } +impl From for FlattenOption { + fn from(value: settings::FlattenOption) -> Self { + match value { + settings::FlattenOption::Never => Self::Never, + settings::FlattenOption::Always => Self::Always, + } + } +} + +impl From> for FlattenOption { + fn from(value: Option) -> Self { + value.map(|x| x.into()).unwrap_or_default() + } +} + +impl From for settings::FlattenOption { + fn from(value: FlattenOption) -> settings::FlattenOption { + match value { + FlattenOption::Never => settings::FlattenOption::Never, + FlattenOption::Always => settings::FlattenOption::Always, + } + } +} + #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum HelpVerbosity { Short, @@ -568,6 +611,51 @@ mod heading { pub const ADVANCED: &str = "Advanced Options"; } +// --- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum ExpansionOption { + Never, + Inline, + Low, + #[default] + Medium, + High, + Always, +} + +impl From for ExpansionOption { + fn from(value: ExpansionMode) -> Self { + match value { + ExpansionMode::Never => Self::Never, + ExpansionMode::Inline => Self::Inline, + ExpansionMode::Low => Self::Low, + ExpansionMode::Medium => Self::Medium, + ExpansionMode::High => Self::High, + ExpansionMode::Always => Self::Always, + } + } +} + +impl From> for ExpansionOption { + fn from(value: Option) -> Self { + Self::from(value.unwrap_or_default()) + } +} + +impl From for ExpansionMode { + fn from(value: ExpansionOption) -> Self { + match value { + ExpansionOption::Never => ExpansionMode::Never, + ExpansionOption::Inline => ExpansionMode::Inline, + ExpansionOption::Low => ExpansionMode::Low, + ExpansionOption::Medium => ExpansionMode::Medium, + ExpansionOption::High => ExpansionMode::High, + ExpansionOption::Always => ExpansionMode::Always, + } + } +} + fn parse_size(s: &str) -> std::result::Result { match bytefmt::parse(s) { Ok(value) => Ok(usize::try_from(value)?), diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 200b5dedf..9e6736817 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -76,3 +76,73 @@ fn test_ascii_option() { } } } + +#[test] +fn test_flatten_option() { + assert_eq!(FlattenOption::from(None), FlattenOption::Always); + assert_eq!( + FlattenOption::from(Some(settings::FlattenOption::Never)), + FlattenOption::Never + ); + assert_eq!( + FlattenOption::from(Some(settings::FlattenOption::Always)), + FlattenOption::Always + ); + assert_eq!( + FlattenOption::from(settings::FlattenOption::Never), + FlattenOption::Never + ); + assert_eq!( + FlattenOption::from(settings::FlattenOption::Always), + FlattenOption::Always + ); + assert_eq!( + Into::::into(FlattenOption::Never), + settings::FlattenOption::Never + ); + assert_eq!( + Into::::into(FlattenOption::Always), + settings::FlattenOption::Always + ); +} + +#[test] +fn test_expansion_option() { + assert_eq!(ExpansionOption::from(None), ExpansionOption::Medium); + assert_eq!( + ExpansionOption::from(Some(settings::ExpansionMode::Medium)), + ExpansionOption::Medium + ); + assert_eq!( + ExpansionOption::from(Some(settings::ExpansionMode::Never)), + ExpansionOption::Never + ); + assert_eq!( + ExpansionOption::from(Some(settings::ExpansionMode::Always)), + ExpansionOption::Always + ); + assert_eq!( + ExpansionOption::from(settings::ExpansionMode::Medium), + ExpansionOption::Medium + ); + assert_eq!( + ExpansionOption::from(settings::ExpansionMode::Never), + ExpansionOption::Never + ); + assert_eq!( + ExpansionOption::from(settings::ExpansionMode::Always), + ExpansionOption::Always + ); + assert_eq!( + Into::::into(ExpansionOption::Medium), + settings::ExpansionMode::Medium + ); + assert_eq!( + Into::::into(ExpansionOption::Never), + settings::ExpansionMode::Never + ); + assert_eq!( + Into::::into(ExpansionOption::Always), + settings::ExpansionMode::Always + ); +} diff --git a/src/datefmt.rs b/src/datefmt.rs index 24a235510..8d26006f8 100644 --- a/src/datefmt.rs +++ b/src/datefmt.rs @@ -16,6 +16,13 @@ use crate::timezone::Tz; // --- +pub struct TextWidth { + pub bytes: usize, + pub chars: usize, +} + +// --- + #[derive(Clone)] pub struct DateTimeFormatter { format: Vec, @@ -57,6 +64,19 @@ impl DateTimeFormatter { self.format(&mut counter, ts); counter.result() } + + pub fn max_width(&self) -> TextWidth { + let mut buf = Vec::new(); + let ts = DateTime::from_timestamp(1654041600, 999_999_999).unwrap().naive_utc(); + let ts = DateTime::from_naive_utc_and_offset(ts, self.tz.offset_from_utc_date(&ts.date()).fix()); + + self.format(&mut buf, ts); + + TextWidth { + bytes: buf.len(), + chars: std::str::from_utf8(&buf).unwrap().chars().count(), + } + } } impl Default for DateTimeFormatter { diff --git a/src/eseq.rs b/src/eseq.rs index c85a01ff4..f0d99d2e8 100644 --- a/src/eseq.rs +++ b/src/eseq.rs @@ -139,7 +139,7 @@ impl From for StyleCode { // --- -#[derive(Clone, Eq, PartialEq, Debug)] +#[derive(Clone, Eq, PartialEq, Debug, Default)] pub struct Sequence { buf: Vec, } diff --git a/src/filtering.rs b/src/filtering.rs index d1b1b28cc..3edf6dea7 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -79,7 +79,6 @@ impl Default for MatchOptions { // --- -#[derive(Default)] pub struct IncludeExcludeKeyFilter { children: HashMap>, patterns: Vec<(Pattern, IncludeExcludeKeyFilter)>, @@ -246,6 +245,16 @@ impl IncludeExcludeKeyFilter { } } +impl Default for IncludeExcludeKeyFilter +where + N: KeyNormalize + Default, +{ + #[inline] + fn default() -> Self { + Self::new(MatchOptions::default()) + } +} + // --- #[derive(PartialEq, Eq, Hash, Debug)] diff --git a/src/fmtx.rs b/src/fmtx.rs index 332a2391b..3eec6e55f 100644 --- a/src/fmtx.rs +++ b/src/fmtx.rs @@ -151,7 +151,7 @@ where } #[inline] - fn push(&mut self, value: T) { + pub fn push(&mut self, value: T) { match self { Self::Disabled(aligner) => aligner.push(value), Self::Unbuffered(aligner) => aligner.push(value), @@ -160,7 +160,7 @@ where } #[inline] - fn extend_from_slice(&mut self, values: &[T]) { + pub fn extend_from_slice(&mut self, values: &[T]) { match self { Self::Disabled(aligner) => aligner.extend_from_slice(values), Self::Unbuffered(aligner) => aligner.extend_from_slice(values), @@ -398,33 +398,33 @@ enum AlignerBuffer { // --- #[inline] -pub fn aligned<'a, T, O, F>(out: &'a mut O, adjustment: Option>, f: F) +pub fn aligned<'a, T, O, R, F>(out: &'a mut O, adjustment: Option>, f: F) -> R where T: Clone, O: Push, - F: FnOnce(Aligner<'a, T, O>), + F: FnOnce(Aligner<'a, T, O>) -> R, { - f(Aligner::new(out, adjustment)); + f(Aligner::new(out, adjustment)) } #[inline] -pub fn aligned_left<'a, T, O, F>(out: &'a mut O, width: usize, pad: T, f: F) +pub fn aligned_left<'a, T, O, R, F>(out: &'a mut O, width: usize, pad: T, f: F) -> R where T: Clone, O: Push, - F: FnOnce(UnbufferedAligner<'a, T, O>), + F: FnOnce(UnbufferedAligner<'a, T, O>) -> R, { - f(UnbufferedAligner::new(out, Padding::new(pad, width))); + f(UnbufferedAligner::new(out, Padding::new(pad, width))) } #[inline] -pub fn centered<'a, T, O, F>(out: &'a mut O, width: usize, pad: T, f: F) +pub fn centered<'a, T, O, R, F>(out: &'a mut O, width: usize, pad: T, f: F) -> R where T: Clone, O: Push, - F: FnOnce(BufferedAligner<'a, T, O>), + F: FnOnce(BufferedAligner<'a, T, O>) -> R, { - f(BufferedAligner::new(out, Padding::new(pad, width), Alignment::Center)); + f(BufferedAligner::new(out, Padding::new(pad, width), Alignment::Center)) } #[cfg(test)] diff --git a/src/formatting.rs b/src/formatting.rs index 1b68328c1..35e7d9d3f 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -1,5 +1,8 @@ // std imports -use std::sync::Arc; +use std::{ + ops::{Deref, DerefMut, Range}, + sync::Arc, +}; // workspace imports use encstr::EncodedString; @@ -7,76 +10,321 @@ use encstr::EncodedString; // local imports use crate::{ ExactIncludeExcludeKeyFilter, IncludeExcludeKeyFilter, - datefmt::DateTimeFormatter, + datefmt::{DateTimeFormatter, TextWidth}, filtering::IncludeExcludeSetting, fmtx::{OptimizedBuf, Push, aligned_left, centered}, - model::{self, Level, RawValue}, - settings::{AsciiMode, Formatting, ResolvedPunctuation}, - theme::{Element, StylingPush, Theme}, + model::{self, Caller, Level, RawValue}, + settings::{self, AsciiMode, ExpansionMode, Formatting, MultilineExpansion, ResolvedPunctuation}, + syntax::*, + theme::{Element, Styler, StylingPush, Theme}, }; +// test imports +#[cfg(test)] +use crate::testing::Sample; + +// relative imports +use string::{DynMessageFormat, ExtendedSpaceAction, Format, ValueFormatAuto}; + // --- -/// Result of formatting a field. -#[derive(Clone, Copy, PartialEq, Eq)] -enum FormatResult { - /// Field was formatted and shown. - Formatted, - /// Field was hidden by user filter (should trigger ellipsis). - HiddenByUser, - /// Field was hidden by predefined filter (silent skip, no ellipsis). - HiddenByPredefined, +type Buf = Vec; + +// --- + +const DEFAULT_EXPANSION_LOW_THRESHOLDS: ExpansionThresholds = ExpansionThresholds { + global: 4096, + cumulative: 512, + message: 256, + field: 128, +}; + +const DEFAULT_EXPANSION_MEDIUM_THRESHOLDS: ExpansionThresholds = ExpansionThresholds { + global: 2048, + cumulative: 256, + message: 192, + field: 64, +}; + +const DEFAULT_EXPANSION_HIGH_THRESHOLDS: ExpansionThresholds = ExpansionThresholds { + global: 1024, + cumulative: 192, + message: 128, + field: 48, +}; + +// --- + +#[derive(Clone, Debug, Default)] +pub struct Expansion { + pub mode: ExpansionMode, + pub profiles: ExpansionProfiles, } -impl FormatResult { - #[inline] - fn is_formatted(self) -> bool { - self == FormatResult::Formatted +impl Expansion { + pub fn with_mode(mut self, mode: ExpansionMode) -> Self { + self.mode = mode; + self } - #[inline] - fn is_hidden_by_user(self) -> bool { - self == FormatResult::HiddenByUser + pub fn profile(&self) -> &ExpansionProfile { + self.profiles.resolve(self.mode) } } -// test imports -#[cfg(test)] -use crate::testing::Sample; +impl From for Expansion { + fn from(options: settings::ExpansionOptions) -> Self { + Self { + mode: options.mode.unwrap_or_default(), + profiles: options.profiles.into(), + } + } +} -// relative imports -use string::{DynMessageFormat, Format, ValueFormatAuto}; +impl From for Expansion { + fn from(mode: settings::ExpansionMode) -> Self { + Self { + mode, + profiles: Default::default(), + } + } +} // --- -type Buf = Vec; +#[derive(Clone, Debug, Default)] +pub struct ExpansionProfiles { + pub low: ExpansionProfileLow, + pub medium: ExpansionProfileMedium, + pub high: ExpansionProfileHigh, +} + +impl ExpansionProfiles { + pub fn resolve(&self, mode: ExpansionMode) -> &ExpansionProfile { + match mode { + ExpansionMode::Never => &ExpansionProfile::NEVER, + ExpansionMode::Always => &ExpansionProfile::ALWAYS, + ExpansionMode::Inline => &ExpansionProfile::INLINE, + ExpansionMode::Low => &self.low, + ExpansionMode::Medium => &self.medium, + ExpansionMode::High => &self.high, + } + } +} + +impl From for ExpansionProfiles { + fn from(options: settings::ExpansionProfiles) -> Self { + Self { + low: options.low.into(), + medium: options.medium.into(), + high: options.high.into(), + } + } +} + +// --- + +#[derive(Clone, Debug)] +pub struct ExpansionProfileLow(ExpansionProfile); + +impl From for ExpansionProfileLow { + fn from(options: settings::ExpansionProfile) -> Self { + Self(Self::default().0.updated(options)) + } +} + +impl Deref for ExpansionProfileLow { + type Target = ExpansionProfile; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ExpansionProfileLow { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for ExpansionProfileLow { + fn default() -> Self { + Self(ExpansionProfile { + multiline: MultilineExpansion::Standard, + thresholds: DEFAULT_EXPANSION_LOW_THRESHOLDS, + }) + } +} + +// --- + +#[derive(Clone, Debug)] +pub struct ExpansionProfileMedium(ExpansionProfile); + +impl From for ExpansionProfileMedium { + fn from(options: settings::ExpansionProfile) -> Self { + Self(Self::default().0.updated(options)) + } +} + +impl Deref for ExpansionProfileMedium { + type Target = ExpansionProfile; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ExpansionProfileMedium { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for ExpansionProfileMedium { + fn default() -> Self { + Self(ExpansionProfile { + multiline: MultilineExpansion::Standard, + thresholds: DEFAULT_EXPANSION_MEDIUM_THRESHOLDS, + }) + } +} + +// --- + +#[derive(Clone, Debug)] +pub struct ExpansionProfileHigh(ExpansionProfile); + +impl From for ExpansionProfileHigh { + fn from(options: settings::ExpansionProfile) -> Self { + Self(Self::default().0.updated(options)) + } +} + +impl Deref for ExpansionProfileHigh { + type Target = ExpansionProfile; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ExpansionProfileHigh { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for ExpansionProfileHigh { + fn default() -> Self { + Self(ExpansionProfile { + multiline: MultilineExpansion::Standard, + thresholds: DEFAULT_EXPANSION_HIGH_THRESHOLDS, + }) + } +} + +// --- + +#[derive(Clone, Debug)] +pub struct ExpansionProfile { + pub multiline: MultilineExpansion, + pub thresholds: ExpansionThresholds, +} + +impl ExpansionProfile { + pub const NEVER: Self = Self { + multiline: MultilineExpansion::Disabled, + thresholds: ExpansionThresholds { + global: usize::MAX, + cumulative: usize::MAX, + message: usize::MAX, + field: usize::MAX, + }, + }; + + pub const ALWAYS: Self = Self { + multiline: MultilineExpansion::Standard, + thresholds: ExpansionThresholds { + global: 0, + cumulative: 0, + message: 256, + field: 0, + }, + }; + + pub const INLINE: Self = Self { + multiline: MultilineExpansion::Inline, + thresholds: ExpansionThresholds { + global: usize::MAX, + cumulative: usize::MAX, + message: usize::MAX, + field: usize::MAX, + }, + }; + + fn update(&mut self, options: settings::ExpansionProfile) { + self.multiline = options.multiline.unwrap_or(self.multiline); + self.thresholds.update(&options.thresholds); + } + + fn updated(mut self, options: settings::ExpansionProfile) -> Self { + self.update(options); + self + } +} + +impl Default for &ExpansionProfile { + fn default() -> Self { + &ExpansionProfile::NEVER + } +} + +// --- + +#[derive(Clone, Debug)] +pub struct ExpansionThresholds { + pub global: usize, + pub cumulative: usize, + pub message: usize, + pub field: usize, +} + +impl ExpansionThresholds { + fn update(&mut self, options: &settings::ExpansionThresholds) { + self.global = options.global.unwrap_or(self.global); + self.cumulative = options.cumulative.unwrap_or(self.cumulative); + self.message = options.message.unwrap_or(self.message); + self.field = options.field.unwrap_or(self.field); + } +} // --- pub trait RecordWithSourceFormatter { - fn format_record(&self, buf: &mut Buf, rec: model::RecordWithSource); + fn format_record(&self, buf: &mut Buf, prefix_range: Range, rec: model::RecordWithSource); } pub struct RawRecordFormatter {} impl RecordWithSourceFormatter for RawRecordFormatter { #[inline(always)] - fn format_record(&self, buf: &mut Buf, rec: model::RecordWithSource) { + fn format_record(&self, buf: &mut Buf, _prefix_range: Range, rec: model::RecordWithSource) { buf.extend_from_slice(rec.source); } } impl RecordWithSourceFormatter for &T { #[inline(always)] - fn format_record(&self, buf: &mut Buf, rec: model::RecordWithSource) { - (**self).format_record(buf, rec) + fn format_record(&self, buf: &mut Buf, prefix_range: Range, rec: model::RecordWithSource) { + (**self).format_record(buf, prefix_range, rec) } } impl RecordWithSourceFormatter for Arc { #[inline(always)] - fn format_record(&self, buf: &mut Buf, rec: model::RecordWithSource) { - (**self).format_record(buf, rec) + fn format_record(&self, buf: &mut Buf, prefix_range: Range, rec: model::RecordWithSource) { + (**self).format_record(buf, prefix_range, rec) } } @@ -87,7 +335,7 @@ pub struct NoOpRecordWithSourceFormatter; impl RecordWithSourceFormatter for NoOpRecordWithSourceFormatter { #[inline(always)] - fn format_record(&self, _: &mut Buf, _: model::RecordWithSource) {} + fn format_record(&self, _: &mut Buf, _: Range, _: model::RecordWithSource) {} } // --- @@ -111,6 +359,7 @@ pub struct RecordFormatterBuilder { cfg: Option, punctuation: Option>, message_format: Option, + expansion: Option, } impl RecordFormatterBuilder { @@ -203,13 +452,20 @@ impl RecordFormatterBuilder { } } + pub fn with_expansion(self, value: Expansion) -> Self { + Self { + expansion: Some(value), + ..self + } + } + pub fn build(self) -> RecordFormatter { let cfg = self.cfg.unwrap_or_default(); let punctuation = self .punctuation .unwrap_or_else(|| cfg.punctuation.resolve(self.ascii).into()); let ts_formatter = self.ts_formatter.unwrap_or_default(); - let ts_width = ts_formatter.max_length(); + let ts_width = ts_formatter.max_width(); RecordFormatter { theme: self.theme.unwrap_or_default(), @@ -226,6 +482,7 @@ impl RecordFormatterBuilder { .message_format .unwrap_or_else(|| DynMessageFormat::new(&cfg, self.ascii)), punctuation, + expansion: self.expansion.unwrap_or_default(), } } } @@ -244,7 +501,7 @@ pub struct RecordFormatter { theme: Arc, unescape_fields: bool, ts_formatter: DateTimeFormatter, - ts_width: usize, + ts_width: TextWidth, hide_empty_fields: bool, flatten: bool, always_show_time: bool, @@ -253,67 +510,62 @@ pub struct RecordFormatter { predefined_fields: Arc, message_format: DynMessageFormat, punctuation: Arc, + expansion: Expansion, } impl RecordFormatter { - pub fn format_record(&self, buf: &mut Buf, rec: &model::Record) { - let mut fs = FormattingState::new(self.flatten && self.unescape_fields); + pub fn format_record(&self, buf: &mut Buf, prefix_range: Range, rec: &model::Record) { + let mut fs = FormattingStateWithRec { + rec, + fs: FormattingState { + flatten: self.flatten && self.unescape_fields, + expansion: self.expansion.profile(), + prefix: prefix_range, + ..Default::default() + }, + }; self.theme.apply(buf, &rec.level, |s| { // // time // - if let Some(ts) = &rec.ts { - fs.add_element(|| {}); - s.element(Element::Time, |s| { - s.batch(|buf| { - aligned_left(buf, self.ts_width, b' ', |mut buf| { - if ts - .as_rfc3339() - .and_then(|ts| self.ts_formatter.reformat_rfc3339(&mut buf, ts)) - .is_none() - { - if let Some(ts) = ts.parse() { - self.ts_formatter.format(&mut buf, ts); - } else { - buf.extend_from_slice(ts.raw().as_bytes()); - } - } - }); - }) - }); - } else if self.always_show_time { - fs.add_element(|| {}); - s.element(Element::Time, |s| { - s.batch(|buf| { - centered(buf, self.ts_width, b'-', |mut buf| { - buf.extend_from_slice(b"-"); - }); - }) - }); + if fs.transact(s, |fs, s| self.format_timestamp(rec, fs, s)).is_err() { + if let Some(ts) = &rec.ts { + fs.extra_fields + .push(("ts", RawValue::String(EncodedString::raw(ts.raw())))) + .ok(); + } else if self.always_show_time { + self.format_timestamp_stub(&mut fs, s); + fs.complexity += 1 + self.ts_width.chars; + } + } else { + fs.complexity += 1 + self.ts_width.chars; } // // level // let level = match rec.level { - Some(Level::Error) => Some(b"ERR"), - Some(Level::Warning) => Some(b"WRN"), - Some(Level::Info) => Some(b"INF"), - Some(Level::Debug) => Some(b"DBG"), - Some(Level::Trace) => Some(b"TRC"), + Some(Level::Error) => Some(LEVEL_ERROR.as_bytes()), + Some(Level::Warning) => Some(LEVEL_WARNING.as_bytes()), + Some(Level::Info) => Some(LEVEL_INFO.as_bytes()), + Some(Level::Debug) => Some(LEVEL_DEBUG.as_bytes()), + Some(Level::Trace) => Some(LEVEL_TRACE.as_bytes()), None => None, }; - let level = level.or(self.always_show_level.then_some(b"(?)")); + let level = level.or(self.always_show_level.then_some(LEVEL_UNKNOWN.as_bytes())); if let Some(level) = level { - fs.add_element(|| s.space()); - s.element(Element::Level, |s| { - s.batch(|buf| { - buf.extend_from_slice(self.punctuation.level_left_separator.as_bytes()); - }); - s.element(Element::LevelInner, |s| s.batch(|buf| buf.extend_from_slice(level))); - s.batch(|buf| buf.extend_from_slice(self.punctuation.level_right_separator.as_bytes())); - }); + fs.has_level = true; + fs.complexity += 3 + level.len(); + self.format_level(s, &mut fs, level); + // fs.add_element(|| s.space()); + // s.element(Element::Level, |s| { + // s.batch(|buf| { + // buf.extend_from_slice(self.punctuation.level_left_separator.as_bytes()); + // }); + // s.element(Element::LevelInner, |s| s.batch(|buf| buf.extend_from_slice(level))); + // s.batch(|buf| buf.extend_from_slice(self.punctuation.level_right_separator.as_bytes())); + // }); } // @@ -326,61 +578,214 @@ impl RecordFormatter { s.batch(|buf| buf.extend_from_slice(logger.as_bytes())) }); s.batch(|buf| buf.extend_from_slice(self.punctuation.logger_name_separator.as_bytes())); + fs.complexity += 2 + logger.len(); + fs.first_line_used = true; }); } + + // include caller into cumulative complexity calculation + if !rec.caller.is_empty() { + fs.complexity += 3 + rec.caller.name.len() + 1 + rec.caller.file.len() + 1 + rec.caller.line.len(); + } + // // message text // if let Some(value) = &rec.message { - self.format_message(s, &mut fs, *value); + match fs.transact(s, |fs, s| self.format_message(s, fs, *value)) { + Ok(()) => { + fs.complexity += 2; + fs.first_line_used = true; + } + Err(MessageFormatError::ExpansionNeeded) => { + self.add_field_to_expand( + s, + &mut fs, + "msg", + *value, + Some(&self.fields), + Some(&self.predefined_fields), + ); + } + Err(MessageFormatError::FormattingAsFieldNeeded) => { + fs.extra_fields.push(("msg", *value)).ok(); + } + Err(MessageFormatError::EmptyMessage) => {} + } } else { s.reset(); } + + match (fs.expansion.thresholds.global, fs.expansion.thresholds.cumulative) { + (0, 0) => {} + (usize::MAX, usize::MAX) => {} + (global, cumulative) => { + if fs.complexity >= cumulative + || self.rough_complexity(fs.complexity, rec, Some(&self.fields)) >= global + { + fs.expanded = true; + } + } + } + // // fields // let mut some_fields_hidden = false; - for (k, v) in rec.fields() { + let x_fields = std::mem::take(&mut fs.extra_fields); + for (k, v) in x_fields.iter().chain(rec.fields()) { if !self.hide_empty_fields || !v.is_empty() { - let result = - self.format_field(s, k, *v, &mut fs, Some(&self.fields), Some(&self.predefined_fields)); - some_fields_hidden |= result.is_hidden_by_user(); + let result = fs.transact(s, |fs, s| { + match self.format_field(s, k, *v, fs, Some(&self.fields), Some(&self.predefined_fields)) { + FieldFormatResult::Ok => { + if !fs.expanded { + fs.first_line_used = true; + } + Ok(()) + } + FieldFormatResult::Hidden => { + some_fields_hidden = true; + Ok(()) + } + FieldFormatResult::HiddenByPredefined => Ok(()), + FieldFormatResult::ExpansionNeeded => Err(()), + } + }); + if let Err(()) = result { + self.add_field_to_expand(s, &mut fs, k, *v, Some(&self.fields), Some(&self.predefined_fields)); + } } } - if some_fields_hidden || (fs.some_nested_fields_hidden && fs.flatten) { + + // + // expanded fields + // + self.expand_enqueued(s, &mut fs); + + if (some_fields_hidden || (fs.some_nested_fields_hidden && fs.flatten)) || fs.some_fields_hidden { + if fs.expanded { + self.expand(s, &mut fs); + } + fs.add_element(|| s.batch(|buf| buf.push(b' '))); s.element(Element::Ellipsis, |s| { s.batch(|buf| buf.extend_from_slice(self.punctuation.hidden_fields_indicator.as_bytes())) }); } + // // caller // - if !rec.caller.is_empty() { - let caller = rec.caller; - s.element(Element::Caller, |s| { - s.batch(|buf| { - buf.push(b' '); - buf.extend(self.punctuation.source_location_separator.as_bytes()) - }); - s.element(Element::CallerInner, |s| { - s.batch(|buf| { - if !caller.name.is_empty() { - buf.extend(caller.name.as_bytes()); - } - if !caller.file.is_empty() || !caller.line.is_empty() { - if !caller.name.is_empty() { - buf.extend(self.punctuation.caller_name_file_separator.as_bytes()); - } - buf.extend(caller.file.as_bytes()); - if !caller.line.is_empty() { - buf.push(b':'); - buf.extend(caller.line.as_bytes()); - } - } - }); - }); + if !fs.caller_formatted && !rec.caller.is_empty() { + self.format_caller(s, &rec.caller); + } + }); + } + + #[inline] + fn format_timestamp>( + &self, + rec: &model::Record, + fs: &mut FormattingStateWithRec, + s: &mut S, + ) -> Result<(), ()> { + let Some(ts) = &rec.ts else { + return Err(()); + }; + + fs.ts_width = self.ts_width.chars; + fs.add_element(|| {}); + s.element(Element::Time, |s| { + s.batch(|buf| { + aligned_left(buf, self.ts_width.bytes, b' ', |mut buf| { + if ts + .as_rfc3339() + .and_then(|ts| self.ts_formatter.reformat_rfc3339(&mut buf, ts)) + .is_some() + { + Ok(()) + } else if let Some(ts) = ts.parse() { + self.ts_formatter.format(&mut buf, ts); + Ok(()) + } else { + Err(()) + } + }) + }) + }) + } + + #[inline] + fn format_timestamp_stub>(&self, fs: &mut FormattingStateWithRec, s: &mut S) { + fs.ts_width = self.ts_width.chars; + fs.add_element(|| {}); + s.element(Element::Time, |s| { + s.batch(|buf| { + centered(buf, self.ts_width.chars, b'-', |mut buf| { + buf.extend_from_slice(b"-"); }); + }) + }); + } + + #[inline] + fn rough_complexity(&self, initial: usize, rec: &model::Record, filter: Option<&IncludeExcludeKeyFilter>) -> usize { + let mut result = initial; + result += rec.message.map(|x| x.raw_str().len()).unwrap_or(0); + result += rec.predefined.len(); + result += rec.logger.map(|x| x.len()).unwrap_or(0); + for (key, value) in rec.fields() { + if value.is_empty() { + if self.hide_empty_fields { + continue; + } + result += 4; + } + + let setting = IncludeExcludeSetting::Unspecified; + let (_, setting, leaf) = match filter { + Some(filter) => { + let setting = setting.apply(filter.setting()); + match filter.get(key) { + Some(filter) => (Some(filter), setting.apply(filter.setting()), filter.leaf()), + None => (None, setting, true), + } + } + None => (None, setting, true), }; + if setting == IncludeExcludeSetting::Exclude && leaf { + continue; + } + + result += 2 + key.len(); + result += value.rough_complexity(); + } + result + } + + #[inline] + fn format_caller>(&self, s: &mut S, caller: &Caller) { + s.element(Element::Caller, |s| { + s.batch(|buf| { + buf.push(b' '); + buf.extend(self.punctuation.source_location_separator.as_bytes()) + }); + s.element(Element::CallerInner, |s| { + s.batch(|buf| { + if !caller.name.is_empty() { + buf.extend(caller.name.as_bytes()); + } + if !caller.file.is_empty() || !caller.line.is_empty() { + if !caller.name.is_empty() { + buf.extend(self.punctuation.caller_name_file_separator.as_bytes()); + } + buf.extend(caller.file.as_bytes()); + if !caller.line.is_empty() { + buf.push(b':'); + buf.extend(caller.line.as_bytes()); + } + } + }); + }); }); } @@ -390,10 +795,10 @@ impl RecordFormatter { s: &mut S, key: &str, value: RawValue<'a>, - fs: &mut FormattingState, + fs: &mut FormattingStateWithRec, filter: Option<&IncludeExcludeKeyFilter>, predefined_filter: Option<&ExactIncludeExcludeKeyFilter>, - ) -> FormatResult { + ) -> FieldFormatResult { let mut fv = FieldFormatter::new(self); fv.format( s, @@ -408,64 +813,265 @@ impl RecordFormatter { } #[inline] - fn format_message<'a, S: StylingPush>(&self, s: &mut S, fs: &mut FormattingState, value: RawValue<'a>) { + fn format_message<'a, S: StylingPush>( + &self, + s: &mut S, + fs: &mut FormattingStateWithRec, + value: RawValue<'a>, + ) -> Result<(), MessageFormatError> { match value { RawValue::String(value) => { if !value.is_empty() { + if value.source().len() > fs.expansion.thresholds.message { + return Err(MessageFormatError::ExpansionNeeded); + } fs.add_element(|| { s.reset(); s.space(); }); s.element(Element::Message, |s| { - s.batch(|buf| self.message_format.format(value, buf).unwrap()) - }); + s.batch(|buf| { + let xsa = match (fs.expanded, fs.expansion.multiline) { + (true, _) => ExtendedSpaceAction::Abort, + (false, MultilineExpansion::Disabled) => ExtendedSpaceAction::Escape, + (false, MultilineExpansion::Standard) => ExtendedSpaceAction::Abort, + (false, MultilineExpansion::Inline) => ExtendedSpaceAction::Inline, + }; + let result = self.message_format.format(value, buf, xsa).unwrap(); + match result { + string::FormatResult::Ok(analysis) => { + if let Some(analysis) = analysis { + fs.complexity += analysis.complexity; + } + Ok(()) + } + string::FormatResult::Aborted => Err(MessageFormatError::ExpansionNeeded), + } + }) + }) + } else { + Err(MessageFormatError::EmptyMessage) } } - _ => { - self.format_field(s, "msg", value, fs, Some(self.fields.as_ref()), None); + _ => Err(MessageFormatError::FormattingAsFieldNeeded), + } + } + + #[inline] + fn format_level>(&self, s: &mut S, fs: &mut FormattingStateWithRec, level: &[u8]) { + fs.add_element(|| s.space()); + s.element(Element::Level, |s| { + s.batch(|buf| { + buf.extend_from_slice(self.punctuation.level_left_separator.as_bytes()); + }); + s.element(Element::LevelInner, |s| s.batch(|buf| buf.extend_from_slice(level))); + s.batch(|buf| buf.extend_from_slice(self.punctuation.level_right_separator.as_bytes())); + }); + } + + #[inline] + fn expand>(&self, s: &mut S, fs: &mut FormattingStateWithRec) { + self.expand_impl(s, fs, true); + } + + #[inline] + fn expand_enqueued>(&self, s: &mut S, fs: &mut FormattingStateWithRec) { + if !fs.fields_to_expand.is_empty() { + self.expand_impl(s, fs, false); + } + } + + fn expand_impl>( + &self, + s: &mut S, + fs: &mut FormattingStateWithRec, + expand_after_enqueued: bool, + ) { + if fs.last_expansion_point == Some(s.batch(|buf| buf.len())) { + return; + } + + let mut begin = fs.prefix.start; + + if !fs.first_line_used { + fs.add_element(|| s.space()); + s.element(Element::Message, |s| { + s.batch(|buf| buf.extend(EXPANDED_MESSAGE_HEADER.as_bytes())); + }); + fs.first_line_used = true; + } + + if !fs.caller_formatted { + if !fs.rec.caller.is_empty() { + self.format_caller(s, &fs.rec.caller); + }; + fs.caller_formatted = true; + } + + s.reset(); + s.batch(|buf| { + buf.push(b'\n'); + begin = buf.len(); + buf.extend_from_within(fs.prefix.clone()); + }); + + fs.dirty = false; + if fs.ts_width != 0 { + fs.dirty = true; + s.element(Element::Time, |s| { + s.batch(|buf| { + aligned_left(buf, fs.ts_width, b' ', |_| {}); + }) + }); + } + + if fs.has_level { + self.format_level(s, fs, LEVEL_EXPANDED.as_bytes()); + s.reset(); + } + + fs.add_element(|| s.space()); + s.batch(|buf| { + for _ in 0..fs.depth + 1 { + buf.extend_from_slice(b" "); + } + }); + + // TODO: remove such hacks and replace with direct access to the buffer + s.batch(|buf| { + let xl = begin..buf.len(); + fs.expansion_prefix = Some(xl.clone()); + }); + + s.element(Element::Bullet, |s| { + s.batch(|buf| { + buf.extend(EXPANDED_KEY_HEADER.as_bytes()); + fs.dirty = false; + fs.last_expansion_point = Some(buf.len()); + }); + }); + + if !fs.fields_to_expand.is_empty() { + fs.expanded = true; + let fields_to_expand = std::mem::take(&mut fs.fields_to_expand); + for (k, v) in fields_to_expand.iter() { + _ = self.format_field(s, k, *v, fs, Some(&self.fields), Some(&self.predefined_fields)); + } + if expand_after_enqueued { + self.expand(s, fs); } } } + + fn add_field_to_expand<'a, S: StylingPush>( + &self, + s: &mut S, + fs: &mut FormattingStateWithRec<'a>, + key: &'a str, + value: RawValue<'a>, + filter: Option<&IncludeExcludeKeyFilter>, + predefined_filter: Option<&ExactIncludeExcludeKeyFilter>, + ) { + let result = if fs.expanded { + Err((key, value)) + } else { + fs.fields_to_expand.push((key, value)) + }; + + if let Err((key, value)) = result { + self.expand(s, fs); + _ = self.format_field(s, key, value, fs, filter, predefined_filter); + } + } } impl RecordWithSourceFormatter for RecordFormatter { #[inline] - fn format_record(&self, buf: &mut Buf, rec: model::RecordWithSource) { - RecordFormatter::format_record(self, buf, rec.record) + fn format_record(&self, buf: &mut Buf, prefix_range: Range, rec: model::RecordWithSource) { + RecordFormatter::format_record(self, buf, prefix_range, rec.record) } } // --- -struct FormattingState { - key_prefix: KeyPrefix, - flatten: bool, - empty: bool, - some_nested_fields_hidden: bool, - has_fields: bool, +struct FormattingStateWithRec<'a> { + fs: FormattingState<'a>, + rec: &'a model::Record<'a>, } -impl FormattingState { - #[inline] - fn new(flatten: bool) -> Self { - Self { - key_prefix: KeyPrefix::default(), - flatten, - empty: true, - some_nested_fields_hidden: false, - has_fields: false, - } - } - +impl<'a> FormattingStateWithRec<'a> { fn add_element(&mut self, add_space: impl FnOnce()) { - if self.empty { - self.empty = false; + if !self.dirty { + self.dirty = true; } else { add_space(); } } + + fn transact(&mut self, s: &mut Styler, f: F) -> Result + where + F: FnOnce(&mut Self, &mut Styler) -> Result, + { + let dirty = self.dirty; + let depth = self.depth; + let first_line_used = self.first_line_used; + let complexity = self.complexity; + let ts_width = self.ts_width; + let result = s.transact(|s| f(self, s)); + if result.is_err() { + self.dirty = dirty; + self.depth = depth; + self.first_line_used = first_line_used; + self.complexity = complexity; + self.ts_width = ts_width; + } + result + } } +impl<'a> Deref for FormattingStateWithRec<'a> { + type Target = FormattingState<'a>; + + #[inline(always)] + fn deref(&self) -> &FormattingState<'a> { + &self.fs + } +} + +impl<'a> DerefMut for FormattingStateWithRec<'a> { + #[inline(always)] + fn deref_mut(&mut self) -> &mut FormattingState<'a> { + &mut self.fs + } +} + +// --- + +#[derive(Default)] +struct FormattingState<'a> { + key_prefix: KeyPrefix, + flatten: bool, + some_nested_fields_hidden: bool, + has_fields: bool, + expansion: &'a ExpansionProfile, + expanded: bool, + prefix: Range, + expansion_prefix: Option>, + dirty: bool, + ts_width: usize, + has_level: bool, + depth: usize, + first_line_used: bool, + some_fields_hidden: bool, + caller_formatted: bool, + complexity: usize, + extra_fields: heapless::Vec<(&'a str, RawValue<'a>), 4>, + fields_to_expand: heapless::Vec<(&'a str, RawValue<'a>), MAX_FIELDS_TO_EXPAND_ON_HOLD>, + last_expansion_point: Option, +} + +const MAX_FIELDS_TO_EXPAND_ON_HOLD: usize = 32; + // --- #[derive(Default)] @@ -525,12 +1131,12 @@ impl<'a> FieldFormatter<'a> { s: &mut S, key: &str, value: RawValue<'a>, - fs: &mut FormattingState, + fs: &mut FormattingStateWithRec, filter: Option<&IncludeExcludeKeyFilter>, setting: IncludeExcludeSetting, predefined_filter: Option<&ExactIncludeExcludeKeyFilter>, predefined_setting: IncludeExcludeSetting, - ) -> FormatResult { + ) -> FieldFormatResult { let (predefined_filter, predefined_setting, predefined_leaf) = match predefined_filter { Some(filter) => { let setting = predefined_setting.apply(filter.setting()); @@ -542,7 +1148,7 @@ impl<'a> FieldFormatter<'a> { None => (None, predefined_setting, true), }; if predefined_setting == IncludeExcludeSetting::Exclude && predefined_leaf { - return FormatResult::HiddenByPredefined; + return FieldFormatResult::HiddenByPredefined; } let (filter, setting, leaf) = match filter { @@ -556,35 +1162,52 @@ impl<'a> FieldFormatter<'a> { None => (None, setting, true), }; if setting == IncludeExcludeSetting::Exclude && leaf { - return FormatResult::HiddenByUser; + return FieldFormatResult::Hidden; } + let key_complexity = key.len() + 2; + + if !fs.expanded && key_complexity + value.raw_str().len() + 2 > fs.expansion.thresholds.field { + return FieldFormatResult::ExpansionNeeded; + } + + // For objects with hide_empty_fields or predefined_filter, track buffer position to rollback if empty let has_predefined_filter = predefined_filter.is_some(); let rollback_pos = if (self.rf.hide_empty_fields || has_predefined_filter) && matches!(value, RawValue::Object(_)) { - Some(s.batch(|buf| buf.len())) + let mut pos = 0; + s.batch(|buf| pos = buf.len()); + Some(pos) } else { None }; let ffv = self.begin(s, key, value, fs); - let has_content = if self.rf.unescape_fields { + + fs.complexity += key_complexity; + + let result = if self.rf.unescape_fields { self.format_value(s, value, fs, filter, predefined_filter, setting, predefined_setting) } else { s.element(Element::String, |s| { s.batch(|buf| buf.extend(value.raw_str().as_bytes())) }); - true + ValueFormatResult::Ok }; self.end(fs, ffv); - match (rollback_pos, has_content) { - (Some(pos), false) => { + // If object had no visible content, rollback buffer and state + if let Some(pos) = rollback_pos { + if result == ValueFormatResult::Empty { s.batch(|buf| buf.truncate(pos)); - FormatResult::HiddenByPredefined + return FieldFormatResult::HiddenByPredefined; } - _ => FormatResult::Formatted, + } + + match result { + ValueFormatResult::Ok | ValueFormatResult::Empty => FieldFormatResult::Ok, + ValueFormatResult::ExpansionNeeded => FieldFormatResult::ExpansionNeeded, } } @@ -593,74 +1216,145 @@ impl<'a> FieldFormatter<'a> { &mut self, s: &mut S, value: RawValue<'a>, - fs: &mut FormattingState, + fs: &mut FormattingStateWithRec, filter: Option<&IncludeExcludeKeyFilter>, predefined_filter: Option<&ExactIncludeExcludeKeyFilter>, setting: IncludeExcludeSetting, predefined_setting: IncludeExcludeSetting, - ) -> bool { + ) -> ValueFormatResult { let value = match value { RawValue::String(EncodedString::Raw(value)) => RawValue::auto(value.as_str()), _ => value, }; + + let complexity_limit = if !fs.expanded { + Some(std::cmp::min( + fs.expansion.thresholds.field, + fs.expansion.thresholds.cumulative - std::cmp::min(fs.expansion.thresholds.cumulative, fs.complexity), + )) + } else { + None + }; + match value { RawValue::String(value) => { - s.element(Element::String, |s| { - s.batch(|buf| ValueFormatAuto.format(value, buf).unwrap()) + let result = s.element(Element::String, |s| { + s.batch(|buf| { + let expand = |buf: &mut Vec| self.add_prefix(buf, fs); + let xsa = match (fs.expanded, fs.expansion.multiline) { + (true, _) => ExtendedSpaceAction::Expand(&expand), + (false, MultilineExpansion::Inline) => ExtendedSpaceAction::Inline, + (false, MultilineExpansion::Disabled) => ExtendedSpaceAction::Escape, + (false, MultilineExpansion::Standard) => ExtendedSpaceAction::Abort, + }; + ValueFormatAuto::default() + .with_complexity_limit(complexity_limit) + .format(value, buf, xsa) + .unwrap() + }) }); + match result { + string::FormatResult::Ok(analysis) => { + if let Some(analysis) = analysis { + fs.complexity += analysis.complexity; + } + } + string::FormatResult::Aborted => { + return ValueFormatResult::ExpansionNeeded; + } + } } RawValue::Number(value) => { s.element(Element::Number, |s| s.batch(|buf| buf.extend(value.as_bytes()))); + fs.complexity += value.len(); } RawValue::Boolean(true) => { s.element(Element::BooleanTrue, |s| s.batch(|buf| buf.extend(b"true"))); + fs.complexity += 4; } RawValue::Boolean(false) => { s.element(Element::BooleanFalse, |s| s.batch(|buf| buf.extend(b"false"))); + fs.complexity += 5; } RawValue::Null => { s.element(Element::Null, |s| s.batch(|buf| buf.extend(b"null"))); + fs.complexity += 4; } RawValue::Object(value) => { - let mut item = model::Object::default(); - value.parse_into(&mut item).ok(); - let mut any_fields_formatted = false; - s.element(Element::Object, |s| { - if !fs.flatten { - s.batch(|buf| buf.push(b'{')); + if let Some(limit) = complexity_limit { + if !fs.flatten && value.rough_complexity() > limit { + return ValueFormatResult::ExpansionNeeded; } - let mut some_fields_hidden_by_user = false; - for (k, v) in item.fields.iter() { - if !self.rf.hide_empty_fields || !v.is_empty() { - let result = - self.format(s, k, *v, fs, filter, setting, predefined_filter, predefined_setting); - any_fields_formatted |= result.is_formatted(); - some_fields_hidden_by_user |= result.is_hidden_by_user(); - } else { - some_fields_hidden_by_user = true; + } + + fs.complexity += 4; + let item = value.parse().unwrap(); + if !fs.flatten && (!fs.expanded || value.is_empty()) { + s.element(Element::Object, |s| { + s.batch(|buf| buf.push(b'{')); + }); + } + let mut some_fields_hidden_by_user = false; + let mut any_fields_formatted = false; + for (k, v) in item.fields.iter() { + if !self.rf.hide_empty_fields || !v.is_empty() { + match self.format(s, k, *v, fs, filter, setting, predefined_filter, predefined_setting) { + FieldFormatResult::Ok => { + any_fields_formatted = true; + } + FieldFormatResult::Hidden => { + some_fields_hidden_by_user = true; + } + FieldFormatResult::HiddenByPredefined => {} + FieldFormatResult::ExpansionNeeded => { + return ValueFormatResult::ExpansionNeeded; + } } + } else { + some_fields_hidden_by_user = true; } + } + if some_fields_hidden_by_user { if !fs.flatten { - if some_fields_hidden_by_user { - s.element(Element::Ellipsis, |s| { - s.batch(|buf| buf.extend(self.rf.punctuation.hidden_fields_indicator.as_bytes())) - }); + if fs.expanded { + self.rf.expand(s, fs); } + fs.add_element(|| s.batch(|buf| buf.push(b' '))); + s.element(Element::Ellipsis, |s| { + s.batch(|buf| buf.extend(self.rf.punctuation.hidden_fields_indicator.as_bytes())) + }); + } else { + fs.some_fields_hidden = true; + } + } + if !fs.flatten && (!fs.expanded || value.is_empty()) { + s.element(Element::Object, |s| { s.batch(|buf| { if !item.fields.is_empty() { buf.push(b' '); } buf.push(b'}'); }); - } - fs.some_nested_fields_hidden |= some_fields_hidden_by_user; - }); - return any_fields_formatted; + }); + } + fs.some_nested_fields_hidden |= some_fields_hidden_by_user; + // Return Empty if no fields were actually formatted (for rollback support) + if !any_fields_formatted { + return ValueFormatResult::Empty; + } } RawValue::Array(value) => { + if let Some(limit) = complexity_limit { + if value.rough_complexity() > limit { + return ValueFormatResult::ExpansionNeeded; + } + } + + fs.complexity += 4; + let xb = std::mem::replace(&mut fs.expanded, false); + let saved_expansion = std::mem::replace(&mut fs.expansion, &ExpansionProfile::INLINE); + let item = value.parse::<32>().unwrap(); s.element(Element::Array, |s| { - let mut item = model::Array::default(); - value.parse_into::<32>(&mut item).ok(); s.batch(|buf| buf.push(b'[')); let mut first = true; for v in item.iter() { @@ -669,7 +1363,7 @@ impl<'a> FieldFormatter<'a> { } else { first = false; } - self.format_value( + _ = self.format_value( s, *v, fs, @@ -681,9 +1375,22 @@ impl<'a> FieldFormatter<'a> { } s.batch(|buf| buf.push(b']')); }); + fs.expansion = saved_expansion; + fs.expanded = xb; } }; - true + + ValueFormatResult::Ok + } + + fn add_prefix(&self, buf: &mut Vec, fs: &FormattingStateWithRec) -> usize { + buf.extend(self.rf.theme.expanded_value_suffix.value.as_bytes()); + buf.push(b'\n'); + let prefix = fs.expansion_prefix.clone().unwrap_or(fs.prefix.clone()); + let l0 = buf.len(); + buf.extend_from_within(prefix); + buf.extend(self.rf.theme.expanded_value_prefix.value.as_bytes()); + buf.len() - l0 } #[inline(always)] @@ -692,7 +1399,7 @@ impl<'a> FieldFormatter<'a> { s: &mut S, key: &str, value: RawValue<'a>, - fs: &mut FormattingState, + fs: &mut FormattingStateWithRec, ) -> FormattedFieldVariant { if fs.flatten && matches!(value, RawValue::Object(_)) { return FormattedFieldVariant::Flattened(fs.key_prefix.push(key)); @@ -700,7 +1407,7 @@ impl<'a> FieldFormatter<'a> { if !fs.has_fields { fs.has_fields = true; - if self.rf.message_format.delimited { + if self.rf.message_format.delimited && !fs.expanded { fs.add_element(|| s.space()); s.element(Element::MessageDelimiter, |s| { s.batch(|buf| buf.extend(self.rf.punctuation.message_delimiter.as_bytes())); @@ -710,6 +1417,11 @@ impl<'a> FieldFormatter<'a> { let variant = FormattedFieldVariant::Normal { flatten: fs.flatten }; + if fs.expanded { + self.rf.expand(s, fs); + } + fs.depth += 1; + fs.add_element(|| s.space()); s.element(Element::Key, |s| { s.batch(|buf| { @@ -723,17 +1435,25 @@ impl<'a> FieldFormatter<'a> { key.key_prettify(buf); }); }); + + let sep = if fs.expanded && matches!(value, RawValue::Object(o) if !o.is_empty()) { + EXPANDED_OBJECT_HEADER.as_bytes() + } else { + self.rf.punctuation.field_key_value_separator.as_bytes() + }; + s.element(Element::Field, |s| { - s.batch(|buf| buf.extend(self.rf.punctuation.field_key_value_separator.as_bytes())); + s.batch(|buf| buf.extend(sep)); }); variant } #[inline] - fn end(&mut self, fs: &mut FormattingState, v: FormattedFieldVariant) { + fn end(&mut self, fs: &mut FormattingStateWithRec, v: FormattedFieldVariant) { match v { FormattedFieldVariant::Normal { flatten } => { + fs.depth -= 1; fs.flatten = flatten; } FormattedFieldVariant::Flattened(n) => { @@ -745,6 +1465,31 @@ impl<'a> FieldFormatter<'a> { // --- +#[must_use] +#[derive(PartialEq, Eq)] +enum ValueFormatResult { + Ok, + Empty, + ExpansionNeeded, +} + +#[must_use] +enum FieldFormatResult { + Ok, + Hidden, + HiddenByPredefined, + ExpansionNeeded, +} + +#[must_use] +enum MessageFormatError { + ExpansionNeeded, + FormattingAsFieldNeeded, + EmptyMessage, +} + +// --- + pub trait WithAutoTrim { fn with_auto_trim(&mut self, f: F) -> R where @@ -803,10 +1548,12 @@ pub mod string { // third-party imports use enumset::{EnumSet, EnumSetType, enum_set as mask}; + use thiserror::Error; // workspace imports - use encstr::{AnyEncodedString, EncodedString, JsonAppender, Result}; + use encstr::{AnyEncodedString, EncodedString, JsonAppender}; use enumset_ext::EnumSetExt; + use mline::prefix_lines_within; // local imports use crate::{ @@ -817,8 +1564,26 @@ pub mod string { // --- + /// Error is an error which may occur in the application. + #[derive(Error, Debug, Clone, Copy, PartialEq, Eq)] + pub enum Error { + #[error(transparent)] + ParseError(#[from] encstr::Error), + } + + // --- + + type Result = std::result::Result; + + // --- + pub trait Format { - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()>; + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result; fn rtrim(self, n: usize) -> FormatRightTrimmed where @@ -869,6 +1634,85 @@ pub mod string { // --- + pub trait Analyze { + fn analyze(&self) -> Analysis; + } + + impl Analyze for [u8] { + #[inline] + fn analyze(&self) -> Analysis { + let mut chars = Mask::empty(); + let mut complexity = 0; + self.iter() + .map(|&c| (CHAR_GROUPS[c as usize], COMPLEXITY[c as usize])) + .for_each(|(group, cc)| { + chars |= group; + complexity += cc; + }); + Analysis { chars, complexity } + } + } + + // --- + + #[derive(Clone, Copy)] + pub enum ExtendedSpaceAction<'a> { + Inline, + Expand(&'a dyn Fn(&mut Vec) -> usize), + Escape, + Abort, + } + + // impl<'a> ExtendedSpaceAction<'a> { + // #[inline] + // pub fn map_expand(&self, f: F) -> ExtendedSpaceAction + // where + // F: FnOnce(&P) -> P2, + // { + // match self { + // Self::Expand(prefix) => ExtendedSpaceAction::Expand(f(prefix)), + // Self::Inline => ExtendedSpaceAction::Inline, + // Self::Escape => ExtendedSpaceAction::Escape, + // Self::Abort => ExtendedSpaceAction::Abort, + // } + // } + // } + + #[must_use] + pub enum FormatResult { + Ok(Option), + Aborted, + } + + impl FormatResult { + #[inline] + #[cfg(test)] + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok(_)) + } + } + + // --- + + pub struct Analysis { + pub chars: Mask, + pub complexity: usize, + } + + impl Analysis { + #[inline] + pub fn empty() -> Self { + Self { + chars: Mask::empty(), + complexity: 2, + } + } + } + + // --- + + // --- + pub trait DisplayResolve { type Output: std::fmt::Display; @@ -896,71 +1740,126 @@ pub mod string { // --- - pub struct ValueFormatAuto; + #[derive(Default)] + pub struct ValueFormatAuto { + complexity_limit: Option, + } + + impl ValueFormatAuto { + #[inline] + pub fn with_complexity_limit(self, limit: Option) -> Self { + Self { + complexity_limit: limit, + } + } + } impl Format for ValueFormatAuto { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result { if input.is_empty() { + if let Some(limit) = self.complexity_limit { + if limit < 2 { + return Ok(FormatResult::Aborted); + } + } buf.extend(r#""""#.as_bytes()); - return Ok(()); + return Ok(FormatResult::Ok(Some(Analysis::empty()))); } let begin = buf.len(); - buf.with_auto_trim(|buf| ValueFormatRaw.format(input, buf))?; + _ = buf.with_auto_trim(|buf| ValueFormatRaw.format(input, buf, xsa))?; - let mut mask = Mask::empty(); + let analysis = buf[begin..].analyze(); + let mask = analysis.chars; - buf[begin..].iter().map(|&c| CHAR_GROUPS[c as usize]).for_each(|group| { - mask |= group; - }); - - let plain = if (mask & !(Flag::Other | Flag::Digit | Flag::Dot | Flag::Minus)).is_empty() { - if mask == Flag::Digit { - buf[begin..].len() > MAX_NUMBER_LEN - } else if !mask.contains(Flag::Other) { - !looks_like_number(&buf[begin..]) - } else { - !matches!( - buf[begin..], - [b'{', ..] - | [b'[', ..] - | [b't', b'r', b'u', b'e'] - | [b'f', b'a', b'l', b's', b'e'] - | [b'n', b'u', b'l', b'l'] - ) + if let Some(limit) = self.complexity_limit { + if analysis.complexity > limit { + return Ok(FormatResult::Aborted); } - } else { - false + } + + const NON_PLAIN: Mask = mask!( + Flag::DoubleQuote + | Flag::SingleQuote + | Flag::Control + | Flag::Backslash + | Flag::Space + | Flag::EqualSign + | Flag::NewLine + | Flag::Tab + ); + const POSITIVE_INTEGER: Mask = mask!(Flag::Digit); + const NUMBER: Mask = mask!(Flag::Digit | Flag::Dot | Flag::Minus); + + let confusing = || { + matches!( + buf[begin..], + [b'{', ..] + | [b'[', ..] + | [b't', b'r', b'u', b'e'] + | [b'f', b'a', b'l', b's', b'e'] + | [b'n', b'u', b'l', b'l'] + ) + }; + + let like_number = || { + (mask == POSITIVE_INTEGER && buf[begin..].len() <= MAX_NUMBER_LEN) + || (!mask.intersects(!NUMBER) && looks_like_number(&buf[begin..])) }; - if plain { - return Ok(()); + if !mask.intersects(NON_PLAIN) && !like_number() && !confusing() { + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::DoubleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf.push(b'"'); buf.push(b'"'); buf[begin..].rotate_right(1); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::SingleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf.push(b'\''); buf.push(b'\''); buf[begin..].rotate_right(1); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } - if !mask.intersects(Flag::Backtick | Flag::Control) { - buf.push(b'`'); - buf.push(b'`'); - buf[begin..].rotate_right(1); - return Ok(()); + const Z: Mask = Mask::empty(); + const XS: Mask = mask!(Flag::NewLine | Flag::Tab); + const BT: Mask = mask!(Flag::Backtick); + const CTL: Mask = mask!(Flag::Control); + + match (mask & CTL, (mask & BT, (mask & XS) != Z), xsa) { + (Z, (Z, false), _) | (Z, (Z, true), ExtendedSpaceAction::Inline) => { + buf.push(b'`'); + buf.push(b'`'); + buf[begin..].rotate_right(1); + Ok(FormatResult::Ok(Some(analysis))) + } + (Z, _, ExtendedSpaceAction::Expand(prefix)) => { + let l0 = buf.len(); + let pl = prefix(buf); + let n = buf.len() - l0; + buf[begin..].rotate_right(n); + prefix_lines_within(buf, begin + n.., 1.., (begin + n - pl)..(begin + n)); + Ok(FormatResult::Ok(Some(analysis))) + } + (Z, _, ExtendedSpaceAction::Abort) => { + buf.truncate(begin); + Ok(FormatResult::Aborted) + } + _ => { + buf.truncate(begin); + ValueFormatDoubleQuoted.format(input, buf, xsa) + } } - - buf.truncate(begin); - ValueFormatDoubleQuoted.format(input, buf) } } @@ -970,8 +1869,14 @@ pub mod string { impl Format for ValueFormatRaw { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { - input.decode(buf) + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + _: ExtendedSpaceAction<'a>, + ) -> Result { + input.decode(buf)?; + Ok(FormatResult::Ok(None)) } } @@ -981,8 +1886,14 @@ pub mod string { impl Format for ValueFormatDoubleQuoted { #[inline] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { - input.format_json(buf) + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + _: ExtendedSpaceAction<'a>, + ) -> Result { + input.format_json(buf)?; + Ok(FormatResult::Ok(None)) } } @@ -992,49 +1903,81 @@ pub mod string { impl Format for MessageFormatAutoQuoted { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result { if input.is_empty() { - return Ok(()); + buf.extend(r#""""#.as_bytes()); + return Ok(FormatResult::Ok(Some(Analysis::empty()))); } let begin = buf.len(); - buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf))?; - - let mut mask = Mask::empty(); - - buf[begin..].iter().map(|&c| CHAR_GROUPS[c as usize]).for_each(|group| { - mask |= group; - }); - - if !mask.intersects(Flag::EqualSign | Flag::Control | Flag::NewLine | Flag::Backslash) - && !matches!(buf[begin..], [b'"', ..] | [b'\'', ..] | [b'`', ..]) + _ = buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf, xsa))?; + + let analysis = buf[begin..].analyze(); + let mask = analysis.chars; + + const NOT_PLAIN: Mask = mask!( + Flag::EqualSign | Flag::Control | Flag::NewLine | Flag::Backslash // | Flag::Colon + // | Flag::Tilde + // | Flag::AngleBrackets + // | Flag::DoubleQuote + // | Flag::SingleQuote + // | Flag::Backtick + ); + + if !mask.intersects(NOT_PLAIN) + && (begin == buf.len() + || !CHAR_GROUPS[buf[begin] as usize] + .intersects(Flag::DoubleQuote | Flag::SingleQuote | Flag::Backtick)) { - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } + // if !mask.intersects(Flag::EqualSign | Flag::Control | Flag::NewLine | Flag::Backslash) + // && !matches!(buf[begin..], [b'"', ..] | [b'\'', ..] | [b'`', ..]) + // { + // return Ok(()); + // } + if !mask.intersects(Flag::DoubleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf.push(b'"'); buf.push(b'"'); buf[begin..].rotate_right(1); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::SingleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf.push(b'\''); buf.push(b'\''); buf[begin..].rotate_right(1); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } - if !mask.intersects(Flag::Backtick | Flag::Control) { - buf.push(b'`'); - buf.push(b'`'); - buf[begin..].rotate_right(1); - return Ok(()); + const Z: Mask = Mask::empty(); + const XS: Mask = mask!(Flag::NewLine | Flag::Tab); + const BT: Mask = mask!(Flag::Backtick); + const CTL: Mask = mask!(Flag::Control); + + match (mask & CTL, (mask & BT, (mask & XS) != Z), xsa) { + (Z, (Z, false), _) | (Z, (Z, true), ExtendedSpaceAction::Inline) => { + buf.push(b'`'); + buf.push(b'`'); + buf[begin..].rotate_right(1); + Ok(FormatResult::Ok(Some(analysis))) + } + (Z, _, ExtendedSpaceAction::Abort) => { + buf.truncate(begin); + Ok(FormatResult::Aborted) + } + _ => { + buf.truncate(begin); + MessageFormatDoubleQuoted.format(input, buf, xsa) + } } - - buf.truncate(begin); - MessageFormatDoubleQuoted.format(input, buf) } } @@ -1044,41 +1987,42 @@ pub mod string { impl Format for MessageFormatAlwaysQuoted { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result { if input.is_empty() { - return Ok(()); + return Ok(FormatResult::Ok(Some(Analysis::empty()))); } let begin = buf.len(); buf.push(b'"'); - buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf))?; + _ = buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf, xsa))?; - let mut mask = Mask::empty(); - - let body = begin + 1; - buf[body..].iter().map(|&c| CHAR_GROUPS[c as usize]).for_each(|group| { - mask |= group; - }); + let analysis = buf[begin + 1..].analyze(); + let mask = analysis.chars; if !mask.intersects(Flag::DoubleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf.push(b'"'); - return Ok(()); + return Ok(FormatResult::Ok(None)); } if !mask.intersects(Flag::SingleQuote | Flag::Control | Flag::Tab | Flag::NewLine | Flag::Backslash) { buf[begin] = b'\''; buf.push(b'\''); - return Ok(()); + return Ok(FormatResult::Ok(None)); } if !mask.intersects(Flag::Backtick | Flag::Control) { buf[begin] = b'`'; buf.push(b'`'); - return Ok(()); + return Ok(FormatResult::Ok(None)); } buf.truncate(begin); - MessageFormatDoubleQuoted.format(input, buf) + MessageFormatDoubleQuoted.format(input, buf, xsa) } } @@ -1094,26 +2038,35 @@ pub mod string { impl Format for MessageFormatDelimited { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result { if input.is_empty() { - return Ok(()); + return Ok(FormatResult::Ok(None)); } let begin = buf.len(); - buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf))?; + _ = buf.with_auto_trim(|buf| MessageFormatRaw.format(input, buf, xsa))?; - let mut mask = Mask::empty(); + let analysis = buf[begin..].analyze(); + let mask = analysis.chars; - buf[begin..].iter().map(|&c| CHAR_GROUPS[c as usize]).for_each(|group| { - mask |= group; - }); + // Check for extended spaces (newlines/tabs) and abort if requested + const XS: Mask = mask!(Flag::NewLine | Flag::Tab); + if (mask & XS) != Mask::empty() && matches!(xsa, ExtendedSpaceAction::Abort) { + buf.truncate(begin); + return Ok(FormatResult::Aborted); + } if !mask.contains(Flag::Control) && !matches!(buf[begin..], [b'"', ..] | [b'\'', ..] | [b'`', ..]) && memchr::memmem::find(&buf[begin..], self.0.as_bytes()).is_none() { buf.extend(self.0.as_bytes()); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::DoubleQuote | Flag::Control | Flag::Backslash) { @@ -1121,7 +2074,7 @@ pub mod string { buf.push(b'"'); buf[begin..].rotate_right(1); buf.extend(self.0.as_bytes()); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::SingleQuote | Flag::Control | Flag::Backslash) { @@ -1129,7 +2082,7 @@ pub mod string { buf.push(b'\''); buf[begin..].rotate_right(1); buf.extend(self.0.as_bytes()); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } if !mask.intersects(Flag::Backtick | Flag::Control) { @@ -1137,13 +2090,13 @@ pub mod string { buf.push(b'`'); buf[begin..].rotate_right(1); buf.extend(self.0.as_bytes()); - return Ok(()); + return Ok(FormatResult::Ok(Some(analysis))); } buf.truncate(begin); - MessageFormatDoubleQuoted.format(input, buf)?; + let result = MessageFormatDoubleQuoted.format(input, buf, xsa)?; buf.extend(self.0.as_bytes()); - Ok(()) + Ok(result) } } @@ -1153,8 +2106,14 @@ pub mod string { impl Format for MessageFormatRaw { #[inline(always)] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { - input.decode(buf) + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + _: ExtendedSpaceAction<'a>, + ) -> Result { + input.decode(buf)?; + Ok(FormatResult::Ok(None)) } } @@ -1164,8 +2123,14 @@ pub mod string { impl Format for MessageFormatDoubleQuoted { #[inline] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { - input.format_json(buf) + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + _: ExtendedSpaceAction<'a>, + ) -> Result { + input.format_json(buf)?; + Ok(FormatResult::Ok(None)) } } @@ -1184,11 +2149,16 @@ pub mod string { impl Format for FormatRightTrimmed { #[inline] - fn format<'a>(&self, input: EncodedString<'a>, buf: &mut Vec) -> Result<()> { + fn format<'a>( + &self, + input: EncodedString<'a>, + buf: &mut Vec, + xsa: ExtendedSpaceAction<'a>, + ) -> Result { let begin = buf.len(); - self.inner.format(input, buf)?; + let result = self.inner.format(input, buf, xsa)?; buf.truncate(buf.len() - min(buf.len() - begin, self.n)); - Ok(()) + Ok(result) } } @@ -1226,16 +2196,50 @@ pub mod string { const HY: Mask = mask!(Flag::Minus); // Hyphen, 0x2D const DO: Mask = mask!(Flag::Dot); // Dot, 0x2E const DD: Mask = mask!(Flag::Digit); // Decimal digit, 0x30..0x39 + const CL: Mask = mask!(Flag::Colon); // Colon, 0x3A + const TL: Mask = mask!(Flag::Tilde); // Tilde, 0x7E + const PA: Mask = mask!(Flag::Parantheses); // 0x28, 0x29 + const BK: Mask = mask!(Flag::Brackets); // 0x5B, 0x5D + const BR: Mask = mask!(Flag::Braces); // 0x7B, 0x7D + const AB: Mask = mask!(Flag::AngleBrackets); // 0x3C, 0x3E const __: Mask = mask!(Flag::Other); [ // 1 2 3 4 5 6 7 8 9 A B C D E F CT, CT, CT, CT, CT, CT, CT, CT, CT, TB, NL, CT, CT, NL, CT, CT, // 0 CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, // 1 SP, __, DQ, __, __, __, __, SQ, __, __, __, __, __, HY, DO, __, // 2 - DD, DD, DD, DD, DD, DD, DD, DD, DD, DD, __, __, __, EQ, __, __, // 3 + DD, DD, DD, DD, DD, DD, DD, DD, DD, DD, CL, __, AB, EQ, AB, __, // 3 + __, __, __, __, __, __, __, __, PA, PA, __, __, __, __, __, __, // 4 + __, __, __, __, __, __, __, __, __, __, __, BK, BS, BK, __, __, // 5 + BT, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 6 + __, __, __, __, __, __, __, __, __, __, __, BR, __, BR, TL, __, // 7 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 8 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 9 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // A + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // B + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // C + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // D + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // E + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // F + ] + }; + + static COMPLEXITY: [usize; 256] = { + const XS: usize = 32; + const CT: usize = 8; + const QU: usize = 4; + const EQ: usize = 4; + const BS: usize = 8; + const __: usize = 1; + [ + // 1 2 3 4 5 6 7 8 9 A B C D E F + CT, CT, CT, CT, CT, CT, CT, CT, CT, XS, XS, CT, CT, XS, CT, CT, // 0 + CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, CT, // 1 + __, __, QU, __, __, __, __, QU, __, __, __, __, __, __, __, __, // 2 + __, __, __, __, __, __, __, __, __, __, __, __, __, EQ, __, __, // 3 __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 4 __, __, __, __, __, __, __, __, __, __, __, __, BS, __, __, __, // 5 - BT, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 6 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 6 __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 7 __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 8 __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 9 @@ -1249,7 +2253,7 @@ pub mod string { }; #[derive(EnumSetType)] - enum Flag { + pub enum Flag { Control, DoubleQuote, SingleQuote, @@ -1262,10 +2266,16 @@ pub mod string { Digit, Minus, Dot, + Colon, + Tilde, + Parantheses, + Brackets, + Braces, + AngleBrackets, Other, } - type Mask = EnumSet; + pub type Mask = EnumSet; } #[cfg(test)] diff --git a/src/formatting/tests.rs b/src/formatting/tests.rs index 13836122a..d9541ee3b 100644 --- a/src/formatting/tests.rs +++ b/src/formatting/tests.rs @@ -1,7 +1,7 @@ use super::{string::new_message_format, *}; use crate::{ datefmt::LinuxDateFormat, - model::{Caller, RawObject, Record, RecordFields, RecordWithSourceConstructor}, + model::{Caller, RawArray, RawObject, Record, RecordFields, RecordWithSourceConstructor}, settings::{AsciiMode, MessageFormat, MessageFormatting}, testing::Sample, timestamp::Timestamp, @@ -9,6 +9,7 @@ use crate::{ }; use chrono::{Offset, Utc}; use encstr::EncodedString; +use itertools::Itertools; use serde_json as json; trait FormatToVec { @@ -22,7 +23,7 @@ trait FormatToString { impl FormatToVec for RecordFormatter { fn format_to_vec(&self, rec: &Record) -> Vec { let mut buf = Vec::new(); - self.format_record(&mut buf, rec); + self.format_record(&mut buf, 0..0, rec); buf } } @@ -42,6 +43,7 @@ fn formatter() -> RecordFormatterBuilder { )) .with_options(Formatting { flatten: None, + expansion: Default::default(), message: MessageFormatting { format: MessageFormat::AutoQuoted, }, @@ -57,6 +59,17 @@ fn format_no_color(rec: &Record) -> String { formatter().with_theme(Default::default()).build().format_to_string(rec) } +fn format_no_color_inline(rec: &Record) -> String { + formatter() + .with_theme(Default::default()) + .with_expansion(Expansion { + mode: ExpansionMode::Inline, + ..Default::default() + }) + .build() + .format_to_string(rec) +} + fn json_raw_value(s: &str) -> Box { json::value::RawValue::from_string(s.into()).unwrap() } @@ -262,7 +275,7 @@ fn test_string_value_json_space_and_double_and_single_quotes_and_backticks() { let v = r#""some \"value\" from 'source' with `sauce`""#; let rec = Record::from_fields(&[("k", EncodedString::json(v).into())]); assert_eq!( - &format_no_color(&rec), + &format_no_color_inline(&rec), r#"k="some \"value\" from 'source' with `sauce`""# ); } @@ -272,7 +285,7 @@ fn test_string_value_raw_space_and_double_and_single_quotes_and_backticks() { let v = r#"some "value" from 'source' with `sauce`"#; let rec = Record::from_fields(&[("k", EncodedString::raw(v).into())]); assert_eq!( - &format_no_color(&rec), + &format_no_color_inline(&rec), r#"k="some \"value\" from 'source' with `sauce`""# ); } @@ -281,14 +294,28 @@ fn test_string_value_raw_space_and_double_and_single_quotes_and_backticks() { fn test_string_value_json_tabs() { let v = r#""some\tvalue""#; let rec = Record::from_fields(&[("k", EncodedString::json(v).into())]); - assert_eq!(&format_no_color(&rec), "k=`some\tvalue`"); + assert_eq!(&format_no_color_inline(&rec), "k=`some\tvalue`"); +} + +#[test] +fn test_string_value_json_tabs_expand() { + let v = r#""some\tvalue""#; + let rec = Record::from_fields(&[("k", EncodedString::json(v).into())]); + assert_eq!(&format_no_color(&rec), "~\n > k=|=>\n \tsome\tvalue"); } #[test] fn test_string_value_raw_tabs() { let v = "some\tvalue"; let rec = Record::from_fields(&[("k", EncodedString::raw(v).into())]); - assert_eq!(&format_no_color(&rec), "k=`some\tvalue`"); + assert_eq!(&format_no_color_inline(&rec), "k=`some\tvalue`"); +} + +#[test] +fn test_string_value_raw_tabs_expand() { + let v = "some\tvalue"; + let rec = Record::from_fields(&[("k", EncodedString::raw(v).into())]); + assert_eq!(&format_no_color(&rec), "~\n > k=|=>\n \tsome\tvalue"); } #[test] @@ -354,7 +381,7 @@ fn test_string_value_json_number() { ] { let qv = format!(r#""{}""#, v); let rec = Record::from_fields(&[("k", EncodedString::json(&qv).into())]); - assert_eq!(format_no_color(&rec), format!(r#"k={}"#, v)); + assert_eq!(format_no_color_inline(&rec), format!(r#"k={}"#, v)); } } @@ -369,7 +396,7 @@ fn test_string_value_raw_number() { "42.128731867381927389172983718293789127389172938712983718927", ] { let rec = Record::from_fields(&[("k", EncodedString::raw(v).into())]); - assert_eq!(format_no_color(&rec), format!(r#"k={}"#, v)); + assert_eq!(format_no_color_inline(&rec), format!(r#"k={}"#, v)); } } @@ -527,6 +554,7 @@ fn test_nested_hidden_fields_no_flatten() { flatten: false, theme: Some(Default::default()), // No theme for consistent test output fields: Some(fields.into()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -554,7 +582,7 @@ fn test_no_op_record_with_source_formatter() { let formatter = NoOpRecordWithSourceFormatter; let rec = Record::default(); let rec = rec.with_source(b"src"); - formatter.format_record(&mut Buf::default(), rec); + formatter.format_record(&mut Buf::default(), 0..0, rec); } #[test] @@ -796,6 +824,21 @@ fn test_punctuation_with_ascii_mode() { assert_ne!(ascii_result, utf8_result); } +#[test] +fn test_string_value_json_extended_space() { + let v = r#""some\tvalue""#; + let rec = Record::from_fields(&[("k", EncodedString::json(v).into())]); + assert_eq!( + format_no_color(&rec), + format!( + "{mh}\n > k={vh}\n {vi}some\tvalue", + mh = EXPANDED_MESSAGE_HEADER, + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ) + ); +} + #[test] fn test_hide_empty_fields_nested_flatten() { let val = json_raw_value(r#"{"nested":{"empty":"","nonempty":"value"},"top_empty":""}"#); @@ -806,6 +849,7 @@ fn test_hide_empty_fields_nested_flatten() { flatten: true, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -815,6 +859,7 @@ fn test_hide_empty_fields_nested_flatten() { flatten: true, hide_empty_fields: false, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -842,6 +887,7 @@ fn test_hide_empty_fields_nested_no_flatten() { flatten: false, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -851,6 +897,7 @@ fn test_hide_empty_fields_nested_no_flatten() { flatten: false, hide_empty_fields: false, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -878,6 +925,7 @@ fn test_hide_empty_fields_no_ellipsis_when_no_empty_fields() { flatten: true, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -891,6 +939,253 @@ fn test_hide_empty_fields_no_ellipsis_when_no_empty_fields() { ); } +#[test] +fn test_string_value_raw_extended_space() { + let v = "some\tvalue"; + let rec = Record::from_fields(&[("k", EncodedString::raw(v).into())]); + assert_eq!( + format_no_color(&rec), + format!( + "{mh}\n > k={vh}\n {vi}some\tvalue", + mh = EXPANDED_MESSAGE_HEADER, + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ) + ); +} + +#[test] +fn test_expand_with_hidden() { + let mut fields = IncludeExcludeKeyFilter::default(); + fields.entry("b").exclude(); + fields.entry("c").entry("z").exclude(); + let formatter = RecordFormatterBuilder { + theme: Default::default(), + flatten: false, + expansion: Some(ExpansionMode::Always.into()), + fields: Some(fields.into()), + ..formatter() + } + .build(); + + let obj = json_raw_value(r#"{"x":10,"y":20,"z":30}"#); + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw("1").into()), + ("b", EncodedString::raw("2").into()), + ("c", RawObject::Json(&obj).into()), + ("d", EncodedString::raw("4").into()), + ]), + ..Default::default() + }; + + let result = formatter.format_to_string(&rec); + assert_eq!( + &result, + "m\n > a=1\n > c:\n > x=10\n > y=20\n > ...\n > d=4\n > ..." + ); +} + +#[test] +fn test_expand_with_hidden_and_flatten() { + let mut fields = IncludeExcludeKeyFilter::default(); + fields.entry("c").entry("z").exclude(); + + let formatter = RecordFormatterBuilder { + theme: Default::default(), + flatten: true, + expansion: Some(ExpansionMode::Always.into()), + fields: Some(fields.into()), + ..formatter() + } + .build(); + + let obj = json_raw_value(r#"{"x":10,"y":20,"z":30}"#); + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw("1").into()), + ("b", EncodedString::raw("2").into()), + ("c", RawObject::Json(&obj).into()), + ("d", EncodedString::raw("4").into()), + ]), + ..Default::default() + }; + + let result = formatter.format_to_string(&rec); + assert_eq!(&result, "m\n > a=1\n > b=2\n > c.x=10\n > c.y=20\n > d=4\n > ..."); +} + +#[test] +fn test_expand_object() { + let formatter = RecordFormatterBuilder { + theme: Default::default(), + flatten: false, + expansion: Some(ExpansionMode::default().into()), + ..formatter() + } + .build(); + + let obj = json_raw_value(r#"{"x":10,"y":"some\nmultiline\nvalue","z":30}"#); + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw("1").into()), + ("b", EncodedString::raw("2").into()), + ("c", RawObject::Json(&obj).into()), + ("d", EncodedString::raw("4").into()), + ]), + ..Default::default() + }; + + let result = formatter.format_to_string(&rec); + assert_eq!( + &result, + "m a=1 b=2 d=4\n > c:\n > x=10\n > y=|=>\n \tsome\n \tmultiline\n \tvalue\n > z=30" + ); +} + +#[test] +fn test_expand_global_threshold() { + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.global = 2; + + let formatter = RecordFormatterBuilder { + theme: Default::default(), + expansion: Some(expansion), + ..formatter() + } + .build(); + + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw("1").into()), + ("b", EncodedString::raw("2").into()), + ("c", EncodedString::raw("3").into()), + ]), + ..Default::default() + }; + + let result = formatter.format_to_string(&rec); + assert_eq!(&result, "m\n > a=1\n > b=2\n > c=3", "{}", result); +} + +#[test] +fn test_caller_file_line() { + let format = |file, line| { + let rec = Record { + message: Some(EncodedString::raw("m").into()), + caller: Caller { file, line, name: "" }, + ..Default::default() + }; + + format_no_color(&rec) + }; + + assert_eq!(format("f", "42"), r#"m -> f:42"#); + assert_eq!(format("f", ""), r#"m -> f"#); + assert_eq!(format("", "42"), r#"m -> :42"#); +} + +#[test] +fn test_expand_no_filter() { + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw("1").into()), + ("b", EncodedString::raw("2").into()), + ("c", EncodedString::raw("3").into()), + ]), + ..Default::default() + }; + + let formatter = RecordFormatterBuilder { + theme: Default::default(), + expansion: Some(ExpansionMode::default().into()), + ..formatter() + } + .build(); + + assert_eq!(formatter.format_to_string(&rec), r#"m a=1 b=2 c=3"#); +} + +#[test] +fn test_expand_message() { + let rec = |m, f| Record { + message: Some(EncodedString::raw(m).into()), + fields: RecordFields::from_slice(&[("a", EncodedString::raw(f).into())]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::Medium); + expansion.profiles.medium.thresholds.message = 64; + + let default_theme = formatter().theme; + + let mut formatter = RecordFormatterBuilder { + theme: Default::default(), + expansion: Some(expansion), + ..formatter() + } + .build(); + + let lorem_ipsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; + + assert_eq!( + formatter.format_to_string(&rec(lorem_ipsum, "1")), + format!("a=1\n > msg=\"{}\"", lorem_ipsum) + ); + assert_eq!( + formatter.format_to_string(&rec("", "some\nmultiline\ntext")), + format!( + concat!( + "{mh}\n", + " > a={header}\n", + " {indent}some\n", + " {indent}multiline\n", + " {indent}text" + ), + mh = EXPANDED_MESSAGE_HEADER, + header = EXPANDED_VALUE_HEADER, + indent = EXPANDED_VALUE_INDENT + ) + ); + + assert_eq!( + formatter.format_to_string(&rec("some\nmultiline\ntext", "1")), + format!( + concat!( + "a=1\n", + " > msg={vh}\n", + " {vi}some\n", + " {vi}multiline\n", + " {vi}text", + ), + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ) + ); + + formatter.theme = default_theme.unwrap_or_default(); + + assert_eq!( + formatter.format_to_string(&rec("some\nmultiline\ntext", "1")), + format!( + concat!( + "\u{1b}[0;32ma\u{1b}[0;2m=\u{1b}[0;94m1\u{1b}[0;32m\u{1b}[0m\n", + " \u{1b}[0;2m> \u{1b}[0;32mmsg\u{1b}[0;2m=\u{1b}[0m\u{1b}[0;2m{vh}\u{1b}[0m\n", + " \u{1b}[0;2m {vi}\u{1b}[0msome\n", + " \u{1b}[0;2m {vi}\u{1b}[0mmultiline\n", + " \u{1b}[0;2m {vi}\u{1b}[0mtext\u{1b}[0m", + ), + vh = EXPANDED_VALUE_HEADER, + vi = EXPANDED_VALUE_INDENT, + ) + ); +} + #[test] fn test_hide_empty_objects_flatten() { let val = json_raw_value(r#"{"empty_obj":{},"all_empty":{"a":"","b":""},"has_value":{"a":"","b":"value"}}"#); @@ -901,6 +1196,7 @@ fn test_hide_empty_objects_flatten() { flatten: true, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -910,6 +1206,7 @@ fn test_hide_empty_objects_flatten() { flatten: true, hide_empty_fields: false, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -937,6 +1234,7 @@ fn test_hide_empty_objects_no_flatten() { flatten: false, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -946,6 +1244,7 @@ fn test_hide_empty_objects_no_flatten() { flatten: false, hide_empty_fields: false, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -972,6 +1271,7 @@ fn test_hide_deeply_nested_empty_objects() { flatten: true, hide_empty_fields: true, theme: Some(Default::default()), + expansion: Some(ExpansionMode::Never.into()), ..formatter() } .build(); @@ -979,5 +1279,559 @@ fn test_hide_deeply_nested_empty_objects() { let result_hide = formatter_hide.format_to_string(&rec); // Deeply nested objects with only empty fields should be completely hidden - assert_eq!(&result_hide, " ..."); + assert_eq!(&result_hide, "..."); +} + +#[test] +fn test_expand_multiline_message_always() { + // Test that with ExpansionMode::Always, a multiline message is formatted as msg=|=> + // with proper indentation, not inline breaking the output + let rec = Record { + message: Some(EncodedString::raw("line1\nline2\nline3").into()), + fields: RecordFields::from_slice(&[("field", EncodedString::raw("value").into())]), + ..Default::default() + }; + + let formatter = RecordFormatterBuilder { + theme: Some(Default::default()), + expansion: Some(ExpansionMode::Always.into()), + ..formatter() + } + .build(); + + let result = formatter.format_to_string(&rec); + + // With ExpansionMode::Always, multiline message should be formatted as a field + // msg=|=> followed by properly indented lines + assert_eq!( + &result, + concat!( + "~\n", + " > msg=|=>\n", + " \tline1\n", + " \tline2\n", + " \tline3\n", + " > field=value" + ) + ); +} + +#[test] +fn test_expand_multiline_message_always_with_level_delimited() { + // Test that with ExpansionMode::Always, level present, and Delimited message format + // (matching CLI defaults), a multiline message is formatted as msg=|=> with proper + // indentation, not inline breaking the output + use crate::model::Level; + + let rec = Record { + level: Some(Level::Info), + message: Some(EncodedString::raw("line1\nline2\nline3").into()), + fields: RecordFields::from_slice(&[("field", EncodedString::raw("value").into())]), + ..Default::default() + }; + + let formatter = RecordFormatterBuilder { + theme: Some(Default::default()), + expansion: Some(ExpansionMode::Always.into()), + ..formatter() + } + .with_message_format(new_message_format(MessageFormat::Delimited, "›")) + .build(); + + let result = formatter.format_to_string(&rec); + + // With ExpansionMode::Always and Delimited message format, multiline message + // should be formatted as a field msg=|=> followed by properly indented lines. + // The message should NOT be formatted inline like: + // |INF| line1 + // line2 + // line3 + // | - | > field=value + // Instead it should be expanded properly. + assert_eq!( + &result, + concat!( + "|INF| ~\n", + "| - | > msg=|=>\n", + "| - | \tline1\n", + "| - | \tline2\n", + "| - | \tline3\n", + "| - | > field=value" + ) + ); +} + +#[test] +fn test_expand_multiline_message_always_with_level() { + // Test that with ExpansionMode::Always and level present, a multiline message + // is formatted as msg=|=> with proper indentation, not inline breaking the output + use crate::model::Level; + + let rec = Record { + level: Some(Level::Info), + message: Some(EncodedString::raw("line1\nline2\nline3").into()), + fields: RecordFields::from_slice(&[("field", EncodedString::raw("value").into())]), + ..Default::default() + }; + + let formatter = RecordFormatterBuilder { + theme: Some(Default::default()), + expansion: Some(ExpansionMode::Always.into()), + ..formatter() + } + .build(); + + let result = formatter.format_to_string(&rec); + + // With ExpansionMode::Always, multiline message should be formatted as a field + // msg=|=> followed by properly indented lines, even when level is present + // The message should NOT be formatted inline like: + // [INF] line1 + // line2 + // line3 + // Instead it should be: + // [INF] ~ + // > msg=|=> + // line1 + // line2 + // line3 + // > field=value + assert_eq!( + &result, + concat!( + "|INF| ~\n", + "| - | > msg=|=>\n", + "| - | \tline1\n", + "| - | \tline2\n", + "| - | \tline3\n", + "| - | > field=value" + ) + ); +} + +#[test] +fn test_expand_without_message() { + let rec = |f, ts| Record { + ts, + fields: RecordFields::from_slice(&[("a", EncodedString::raw(f).into())]), + ..Default::default() + }; + + let ts = Timestamp::new("2000-01-02T03:04:05.123Z"); + + let formatter = RecordFormatterBuilder { + theme: Default::default(), + expansion: Some(ExpansionMode::Always.into()), + ..formatter() + } + .build(); + + assert_eq!( + formatter.format_to_string(&rec("1", None)), + format!("{mh}\n > a=1", mh = EXPANDED_MESSAGE_HEADER) + ); + assert_eq!( + formatter.format_to_string(&rec("1", Some(ts))), + format!( + concat!("00-01-02 03:04:05.123 {mh}\n", " > a=1"), + mh = EXPANDED_MESSAGE_HEADER + ) + ); +} + +#[test] +fn test_format_uuid() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", EncodedString::raw(value).into())]), + ..Default::default() + }; + + assert_eq!( + format_no_color(&rec("243e020d-11d6-42f6-b4cd-b4586057b9a2")), + "a=243e020d-11d6-42f6-b4cd-b4586057b9a2" + ); +} + +#[test] +fn test_format_int_string() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", EncodedString::json(value).into())]), + ..Default::default() + }; + + assert_eq!(format_no_color(&rec(r#""243""#)), r#"a="243""#); +} + +#[test] +fn test_format_unparsable_time() { + let rec = |ts, msg| Record { + ts: Some(Timestamp::new(ts)), + level: Some(Level::Info), + message: Some(EncodedString::raw(msg).into()), + ..Default::default() + }; + + assert_eq!( + format_no_color(&rec("some-unparsable-time", "some-msg")), + "|INF| some-msg ts=some-unparsable-time" + ); +} + +#[test] +fn test_format_value_with_eq() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", EncodedString::raw(value).into())]), + ..Default::default() + }; + + assert_eq!(format_no_color(&rec("x=y")), r#"a="x=y""#); + assert_eq!(format_no_color(&rec("|=>")), r#"a="|=>""#); +} + +#[test] +fn test_value_format_auto() { + let vf = string::ValueFormatAuto::default(); + let mut buf = Vec::new(); + let result = vf + .format(EncodedString::raw("test"), &mut buf, ExtendedSpaceAction::Inline) + .unwrap(); + assert_eq!(buf, b"test"); + assert!(result.is_ok()); +} + +#[test] +fn test_message_format_auto_empty() { + let vf = string::MessageFormatAutoQuoted; + let mut buf = Vec::new(); + let result = vf + .format(EncodedString::raw(""), &mut buf, ExtendedSpaceAction::Abort) + .unwrap(); + assert_eq!(buf, br#""""#); + assert!(result.is_ok()); +} + +#[test] +fn test_expand_mode_inline() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", EncodedString::raw(value).into())]), + ..Default::default() + }; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(ExpansionMode::Inline.into()) + .build(); + + assert_eq!( + formatter.format_to_string(&rec("some single-line message")), + r#"a="some single-line message""# + ); + assert_eq!( + formatter.format_to_string(&rec("some\nmultiline\nmessage")), + "a=`some\nmultiline\nmessage`" + ); +} + +#[test] +fn test_expand_mode_low() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", EncodedString::raw(value).into())]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::Low); + expansion.profiles.low.thresholds.global = 1024; + expansion.profiles.low.thresholds.cumulative = 1024; + expansion.profiles.low.thresholds.field = 1024; + expansion.profiles.low.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .build(); + + assert_eq!( + formatter.format_to_string(&rec("some single-line message")), + r#"a="some single-line message""# + ); + assert_eq!( + formatter.format_to_string(&rec("some\nmultiline\nmessage")), + "~\n > a=|=>\n \tsome\n \tmultiline\n \tmessage" + ); +} + +#[test] +fn test_expansion_threshold_cumulative() { + let rec = |msg, v1, v2, v3| Record { + message: Some(EncodedString::raw(msg).into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw(v1).into()), + ("b", EncodedString::raw(v2).into()), + ("c", EncodedString::raw(v3).into()), + ]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.global = 1024; + expansion.profiles.high.thresholds.cumulative = 32; + expansion.profiles.high.thresholds.field = 1024; + expansion.profiles.high.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .build(); + + assert_eq!( + formatter.format_to_string(&rec("", "v1", "v2", "v3")), + r#"a=v1 b=v2 c=v3"# + ); + assert_eq!( + formatter.format_to_string(&rec("m", "v1", "v2", "v3")), + r#"m a=v1 b=v2 c=v3"# + ); + assert_eq!( + formatter.format_to_string(&rec("", "long-v1", "long-v2", "long-v3")), + "a=long-v1 b=long-v2 c=long-v3" + ); + assert_eq!( + formatter.format_to_string(&rec("m", "long-v1", "long-v2", "long-v3")), + "m a=long-v1 b=long-v2\n > c=long-v3" + ); + assert_eq!( + formatter.format_to_string(&rec( + "some long long long long long long message", + "long-v1", + "long-v2", + "long-v3" + )), + "some long long long long long long message\n > a=long-v1\n > b=long-v2\n > c=long-v3" + ); +} + +#[test] +fn test_expansion_threshold_global() { + let rec = |msg, v1, v2, v3| Record { + message: Some(EncodedString::raw(msg).into()), + fields: RecordFields::from_slice(&[ + ("a", EncodedString::raw(v1).into()), + ("b", EncodedString::raw(v2).into()), + ("c", EncodedString::raw(v3).into()), + ]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.global = 28; + expansion.profiles.high.thresholds.cumulative = 1024; + expansion.profiles.high.thresholds.field = 1024; + expansion.profiles.high.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .build(); + + assert_eq!( + formatter.format_to_string(&rec("", "v1", "v2", "v3")), + r#"a=v1 b=v2 c=v3"# + ); + assert_eq!( + formatter.format_to_string(&rec("m", "v1", "v2", "v3")), + r#"m a=v1 b=v2 c=v3"# + ); + assert_eq!( + formatter.format_to_string(&rec("", "long-v1", "long-v2", "long-v3")), + "~\n > a=long-v1\n > b=long-v2\n > c=long-v3" + ); + assert_eq!( + formatter.format_to_string(&rec("m", "long-v1", "long-v2", "long-v3")), + "m\n > a=long-v1\n > b=long-v2\n > c=long-v3" + ); + assert_eq!( + formatter.format_to_string(&rec( + "some long long long long long long message", + "long-v1", + "long-v2", + "long-v3" + )), + "some long long long long long long message\n > a=long-v1\n > b=long-v2\n > c=long-v3" + ); +} + +#[test] +fn test_expansion_threshold_field() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", value)]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.global = 1024; + expansion.profiles.high.thresholds.cumulative = 1024; + expansion.profiles.high.thresholds.field = 48; + expansion.profiles.high.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .with_flatten(false) + .build(); + + let array = json_raw_value("[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]"); + let object = json_raw_value(r#"{"a":"v1","b":"v2","c":"v3","d":"v4","e":"v5","f":"v6"}"#); + + assert_eq!( + formatter.format_to_string(&rec(EncodedString::raw("v").into())), + r#"a=v"# + ); + assert_eq!( + formatter.format_to_string(&rec(RawValue::Array(array.as_ref().into()))), + "~\n > a=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]" + ); + assert_eq!( + formatter.format_to_string(&rec(RawValue::Object(object.as_ref().into()))), + "~\n > a:\n > a=v1\n > b=v2\n > c=v3\n > d=v4\n > e=v5\n > f=v6" + ); +} + +#[test] +fn test_expansion_nested_field() { + let rec = |value| Record { + fields: RecordFields::from_slice(&[("a", value)]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.global = 1024; + expansion.profiles.high.thresholds.cumulative = 1024; + expansion.profiles.high.thresholds.field = 1024; + expansion.profiles.high.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .with_empty_fields_hiding(true) + .with_flatten(false) + .build(); + + let object = + json_raw_value(r#"{"a":"v1","b":"v2","c":{"c":"v3","d":"v4\nwith second line","e":"v5","f":"v6","g":""}}"#); + + assert_eq!( + formatter.format_to_string(&rec(RawValue::Object(object.as_ref().into()))), + "~\n > a:\n > a=v1\n > b=v2\n > c:\n > c=v3\n > d=|=>\n \tv4\n \twith second line\n > e=v5\n > f=v6\n > ..." + ); +} + +#[test] +fn test_add_field_to_expand() { + const M: usize = MAX_FIELDS_TO_EXPAND_ON_HOLD + 2; + let kvs = (0..M) + .map(|i| (format!("k{}", i).to_owned(), format!("some\nvalue #{}", i).to_owned())) + .collect_vec(); + let rec = Record { + message: Some(EncodedString::raw("m").into()), + fields: RecordFields::from_iter( + kvs.iter() + .map(|(k, v)| (k.as_str(), EncodedString::raw(v.as_str()).into())), + ), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::Medium); + expansion.profiles.medium.thresholds.global = 1024; + expansion.profiles.medium.thresholds.cumulative = 320; + expansion.profiles.medium.thresholds.field = 1024; + expansion.profiles.medium.thresholds.message = 1024; + + let formatter = formatter() + .with_theme(Default::default()) + .with_expansion(expansion) + .build(); + + assert_eq!( + formatter.format_to_string(&rec), + "m\n > k0=|=>\n \tsome\n \tvalue #0\n > k1=|=>\n \tsome\n \tvalue #1\n > k2=|=>\n \tsome\n \tvalue #2\n > k3=|=>\n \tsome\n \tvalue #3\n > k4=|=>\n \tsome\n \tvalue #4\n > k5=|=>\n \tsome\n \tvalue #5\n > k6=|=>\n \tsome\n \tvalue #6\n > k7=|=>\n \tsome\n \tvalue #7\n > k8=|=>\n \tsome\n \tvalue #8\n > k9=|=>\n \tsome\n \tvalue #9\n > k10=|=>\n \tsome\n \tvalue #10\n > k11=|=>\n \tsome\n \tvalue #11\n > k12=|=>\n \tsome\n \tvalue #12\n > k13=|=>\n \tsome\n \tvalue #13\n > k14=|=>\n \tsome\n \tvalue #14\n > k15=|=>\n \tsome\n \tvalue #15\n > k16=|=>\n \tsome\n \tvalue #16\n > k17=|=>\n \tsome\n \tvalue #17\n > k18=|=>\n \tsome\n \tvalue #18\n > k19=|=>\n \tsome\n \tvalue #19\n > k20=|=>\n \tsome\n \tvalue #20\n > k21=|=>\n \tsome\n \tvalue #21\n > k22=|=>\n \tsome\n \tvalue #22\n > k23=|=>\n \tsome\n \tvalue #23\n > k24=|=>\n \tsome\n \tvalue #24\n > k25=|=>\n \tsome\n \tvalue #25\n > k26=|=>\n \tsome\n \tvalue #26\n > k27=|=>\n \tsome\n \tvalue #27\n > k28=|=>\n \tsome\n \tvalue #28\n > k29=|=>\n \tsome\n \tvalue #29\n > k30=|=>\n \tsome\n \tvalue #30\n > k31=|=>\n \tsome\n \tvalue #31\n > k32=|=>\n \tsome\n \tvalue #32\n > k33=|=>\n \tsome\n \tvalue #33" + ); +} + +#[test] +fn test_complex_message_expansion() { + let rec = Record { + message: Some(EncodedString::json(r#""\n""#).into()), + fields: RecordFields::from_slice(&[ + ("level", EncodedString::raw("info").into()), + ("ts", EncodedString::raw("2024-06-05T04:25:29Z").into()), + ]), + ..Default::default() + }; + + let mut expansion = Expansion::from(ExpansionMode::High); + expansion.profiles.high.thresholds.cumulative = 32; + expansion.profiles.high.thresholds.field = 1024; + expansion.profiles.high.thresholds.global = 10; + expansion.profiles.high.thresholds.message = 1024; + + let formatter = RecordFormatterBuilder { + theme: Default::default(), + flatten: true, + expansion: Some(expansion), + ..formatter() + } + .build(); + + let result = formatter.format_to_string(&rec); + + assert_eq!( + &result, + "~\n > msg=|=>\n \t\n \t\n > level=info\n > ts=2024-06-05T04:25:29Z" + ); +} + +#[test] +fn test_array_of_objects() { + let ka = json_raw_value(r#"[{"name":"a","value":1},{"name":"b","value":2}]"#); + let rec = Record { + ts: Some(Timestamp::new("2000-01-02T03:04:05.123Z")), + message: Some(RawValue::String(EncodedString::json(r#""test message""#))), + level: Some(Level::Info), + fields: RecordFields::from_slice(&[("items", RawValue::from(RawArray::Json(&ka)))]), + ..Default::default() + }; + + // Test with expansion mode always - this is where the bug occurs + let output = formatter() + .with_theme(Default::default()) + .with_expansion(Expansion { + mode: ExpansionMode::Always, + ..Default::default() + }) + .build() + .format_to_string(&rec); + + // The array should contain the objects, not be empty + assert!( + output.contains("name"), + "Array should contain 'name' field from objects, but got: {}", + output + ); + assert!( + output.contains("value"), + "Array should contain 'value' field from objects, but got: {}", + output + ); + assert!( + output.contains("items"), + "Output should contain 'items' field name, but got: {}", + output + ); + + // Check that both objects are present + assert!( + output.contains("a") && output.contains("b"), + "Array should contain object values 'a' and 'b', but got: {}", + output + ); } diff --git a/src/lib.rs b/src/lib.rs index b36b5df63..796c4290c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod level; pub mod output; pub mod query; pub mod settings; +pub mod syntax; pub mod theme; pub mod themecfg; pub mod timeparse; diff --git a/src/main.rs b/src/main.rs index 973442c92..e6a227fc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -321,6 +321,7 @@ fn run() -> Result<()> { }, flatten: opt.flatten != cli::FlattenOption::Never, ascii, + expand: opt.expansion.into(), }); // Configure the input. diff --git a/src/model.rs b/src/model.rs index d5a4c675c..5f35b31e7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -161,6 +161,20 @@ impl<'a> RawValue<'a> { _ => 0, } } + + #[inline] + pub fn rough_complexity(&self) -> usize { + match self { + Self::String(EncodedString::Json(value)) => 4 + value.source().len(), + Self::String(EncodedString::Raw(value)) => value.source().len(), + Self::Null => 4, + Self::Boolean(false) => 5, + Self::Boolean(true) => 4, + Self::Number(value) => value.len(), + Self::Object(value) => value.rough_complexity(), + Self::Array(value) => value.rough_complexity(), + } + } } impl<'a> From> for RawValue<'a> { @@ -246,6 +260,13 @@ impl<'a> RawObject<'a> { Self::Json(value) => json_match(value, "{}"), } } + + #[inline] + pub fn rough_complexity(&self) -> usize { + match self { + Self::Json(value) => 4 + value.get().len() * 3 / 2, + } + } } impl<'a> From<&'a json::value::RawValue> for RawObject<'a> { @@ -295,6 +316,13 @@ impl<'a> RawArray<'a> { Self::Json(value) => json_match(value, "[]"), } } + + #[inline] + pub fn rough_complexity(&self) -> usize { + match self { + Self::Json(value) => 4 + value.get().len() * 5 / 4, + } + } } impl<'a> From<&'a json::value::RawValue> for RawArray<'a> { @@ -1863,7 +1891,7 @@ fn json_match(value: &json::value::RawValue, s: &str) -> bool { // --- -const RECORD_EXTRA_CAPACITY: usize = 32; +pub(crate) const RECORD_EXTRA_CAPACITY: usize = 32; const MAX_PREDEFINED_FIELDS: usize = 8; const RAW_RECORD_FIELDS_CAPACITY: usize = RECORD_EXTRA_CAPACITY + MAX_PREDEFINED_FIELDS; diff --git a/src/settings.rs b/src/settings.rs index 1028a61ba..e81935073 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -392,6 +392,7 @@ impl FromStr for InputInfo { #[serde(rename_all = "kebab-case")] pub struct Formatting { pub flatten: Option, + pub expansion: ExpansionOptions, pub message: MessageFormatting, pub punctuation: Punctuation, } @@ -401,6 +402,7 @@ impl Sample for Formatting { fn sample() -> Self { Self { flatten: None, + expansion: ExpansionOptions::default(), message: MessageFormatting { format: MessageFormat::AutoQuoted, }, @@ -411,6 +413,122 @@ impl Sample for Formatting { // --- +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ExpansionOptions { + pub mode: Option, + pub profiles: ExpansionProfiles, +} + +impl ExpansionOptions { + pub fn profile(&self) -> Option<&ExpansionProfile> { + self.mode.map(|mode| self.profiles.resolve(mode)) + } +} + +// --- + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ExpansionProfiles { + pub low: ExpansionProfile, + pub medium: ExpansionProfile, + pub high: ExpansionProfile, +} + +impl ExpansionProfiles { + pub fn resolve(&self, mode: ExpansionMode) -> &ExpansionProfile { + match mode { + ExpansionMode::Never => &ExpansionProfile::NEVER, + ExpansionMode::Inline => &ExpansionProfile::INLINE, + ExpansionMode::Low => &self.low, + ExpansionMode::Medium => &self.medium, + ExpansionMode::High => &self.high, + ExpansionMode::Always => &ExpansionProfile::ALWAYS, + } + } +} + +// --- + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ExpansionProfile { + pub multiline: Option, + pub thresholds: ExpansionThresholds, +} + +impl ExpansionProfile { + pub const NEVER: Self = Self { + multiline: Some(MultilineExpansion::Disabled), + thresholds: ExpansionThresholds { + global: Some(usize::MAX), + cumulative: Some(usize::MAX), + message: Some(usize::MAX), + field: Some(usize::MAX), + }, + }; + + pub const INLINE: Self = Self { + multiline: Some(MultilineExpansion::Inline), + thresholds: ExpansionThresholds { + global: Some(usize::MAX), + cumulative: Some(usize::MAX), + message: Some(usize::MAX), + field: Some(usize::MAX), + }, + }; + + pub const ALWAYS: Self = Self { + multiline: Some(MultilineExpansion::Standard), + thresholds: ExpansionThresholds { + global: Some(0), + cumulative: Some(0), + message: Some(0), + field: Some(0), + }, + }; +} + +// --- + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ExpansionThresholds { + pub global: Option, + pub cumulative: Option, + pub message: Option, + pub field: Option, +} + +// --- + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum MultilineExpansion { + #[default] + Standard, + Disabled, + Inline, +} + +// --- + +#[derive(Clone, Copy, Debug, Default, Deserialize, Display, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum ExpansionMode { + Never, + Inline, + Low, + #[default] + Medium, + High, + Always, +} + +// --- + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct MessageFormatting { @@ -520,7 +638,7 @@ impl Default for Punctuation { string_closing_quote: "'".into(), source_location_separator: "@ ".into(), caller_name_file_separator: " ".into(), - hidden_fields_indicator: " ...".into(), + hidden_fields_indicator: "...".into(), level_left_separator: "|".into(), level_right_separator: "|".into(), input_number_prefix: "#".into(), @@ -546,7 +664,7 @@ impl Sample for Punctuation { string_closing_quote: "'".into(), source_location_separator: DisplayVariant::ascii("-> ").unicode("→ "), caller_name_file_separator: " @ ".into(), - hidden_fields_indicator: DisplayVariant::ascii(" ...").unicode(" …"), + hidden_fields_indicator: DisplayVariant::ascii("...").unicode("…"), level_left_separator: "|".into(), level_right_separator: "|".into(), input_number_prefix: "#".into(), diff --git a/src/settings/tests.rs b/src/settings/tests.rs index 4f12800fa..4a9baa4bf 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -233,3 +233,32 @@ fn test_punctuation_resolve() { assert_ne!(ascii_val, utf8_val, "ASCII and Unicode values should be different"); } } + +#[test] +fn test_expansion_options() { + let mut profiles = ExpansionProfiles::default(); + profiles.low.thresholds.global = Some(1); + profiles.low.thresholds.cumulative = Some(2); + profiles.low.thresholds.message = Some(3); + profiles.medium.thresholds.global = Some(4); + profiles.medium.thresholds.field = Some(5); + profiles.high.thresholds.global = Some(6); + profiles.high.thresholds.cumulative = Some(7); + let xo = |mode| ExpansionOptions { + mode, + profiles: profiles.clone(), + }; + assert_eq!(xo(None).profile(), None); + assert_eq!(xo(Some(ExpansionMode::Never)).profile(), Some(&ExpansionProfile::NEVER)); + assert_eq!( + xo(Some(ExpansionMode::Inline)).profile(), + Some(&ExpansionProfile::INLINE) + ); + assert_eq!(xo(Some(ExpansionMode::Low)).profile(), Some(&profiles.low)); + assert_eq!(xo(Some(ExpansionMode::Medium)).profile(), Some(&profiles.medium)); + assert_eq!(xo(Some(ExpansionMode::High)).profile(), Some(&profiles.high)); + assert_eq!( + xo(Some(ExpansionMode::Always)).profile(), + Some(&ExpansionProfile::ALWAYS) + ); +} diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 000000000..202768c25 --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,13 @@ +pub const EXPANDED_KEY_HEADER: &str = "> "; +pub const EXPANDED_VALUE_HEADER: &str = "|=>"; +pub const EXPANDED_VALUE_INDENT: &str = " \t"; +pub const EXPANDED_MESSAGE_HEADER: &str = "~"; +pub const EXPANDED_OBJECT_HEADER: &str = ":"; + +pub const LEVEL_ERROR: &str = "ERR"; +pub const LEVEL_WARNING: &str = "WRN"; +pub const LEVEL_INFO: &str = "INF"; +pub const LEVEL_DEBUG: &str = "DBG"; +pub const LEVEL_TRACE: &str = "TRC"; +pub const LEVEL_UNKNOWN: &str = "(?)"; +pub const LEVEL_EXPANDED: &str = " - "; diff --git a/src/testing/assets/themes/test.toml b/src/testing/assets/themes/test.toml index 84fa22fce..d418e29cb 100644 --- a/src/testing/assets/themes/test.toml +++ b/src/testing/assets/themes/test.toml @@ -52,6 +52,12 @@ foreground = "bright-red" [elements.null] foreground = "bright-red" +[elements.bullet] +modes = ["faint"] + +[elements.value-expansion] +modes = ["faint"] + [levels.trace.level-inner] modes = ["faint"] diff --git a/src/theme.rs b/src/theme.rs index 31b604597..1741631aa 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -11,7 +11,8 @@ use crate::{ error::*, eseq::{Brightness, Color, ColorCode, Mode, Sequence, StyleCode}, fmtx::Push, - level::{self}, + level, + syntax::*, themecfg, }; @@ -40,6 +41,8 @@ pub struct Theme { packs: EnumMap, default: StylePack, pub indicators: IndicatorPack, + pub expanded_value_prefix: ExpandedValuePrefix, + pub expanded_value_suffix: ExpandedValueSuffix, } impl Theme { @@ -58,6 +61,16 @@ impl Theme { default, packs, indicators: IndicatorPack::new(&cfg.indicators), + expanded_value_prefix: cfg + .elements + .get(&Element::ValueExpansion) + .map(ExpandedValuePrefix::from) + .unwrap_or_default(), + expanded_value_suffix: cfg + .elements + .get(&Element::ValueExpansion) + .map(ExpandedValueSuffix::from) + .unwrap_or_default(), } } @@ -118,7 +131,7 @@ impl Sample for Arc { // --- -#[derive(Clone, Eq, PartialEq, Debug)] +#[derive(Clone, Eq, PartialEq, Debug, Default)] struct Style(Sequence); impl Style { @@ -169,12 +182,6 @@ impl Style { } } -impl Default for Style { - fn default() -> Self { - Self::reset() - } -} - impl> From for Style { fn from(value: T) -> Self { Self(value.into()) @@ -231,6 +238,7 @@ impl<'a, B: Push> Styler<'a, B> { if let Some(style) = self.pack.reset { self.pack.styles[style].apply(self.buf) } + self.current = None; self.synced = None; } @@ -255,6 +263,25 @@ impl<'a, B: Push> Styler<'a, B> { } } +impl<'a> Styler<'a, Vec> { + #[inline] + pub fn transact(&mut self, f: F) -> std::result::Result + where + F: FnOnce(&mut Self) -> std::result::Result, + { + let current = self.current; + let synced = self.synced; + let n = self.buf.len(); + let result = f(self); + if result.is_err() { + self.buf.truncate(n); + self.current = current; + self.synced = synced; + } + result + } +} + impl<'a, B: Push> StylingPush for Styler<'a, B> { #[inline] fn element R>(&mut self, element: Element, f: F) -> R { @@ -386,5 +413,63 @@ impl Indicator { // --- +pub struct ExpandedValuePrefix { + pub value: String, +} + +impl From<&themecfg::Style> for ExpandedValuePrefix { + fn from(style: &themecfg::Style) -> Self { + Self { + value: styled(style.into(), &Self::default().value), + } + } +} + +impl Default for ExpandedValuePrefix { + fn default() -> Self { + Self { + value: " ".repeat(EXPANDED_KEY_HEADER.len()) + EXPANDED_VALUE_INDENT, + } + } +} + +// --- + +pub struct ExpandedValueSuffix { + pub value: String, +} + +impl From<&themecfg::Style> for ExpandedValueSuffix { + fn from(style: &themecfg::Style) -> Self { + Self { + value: styled(style.into(), &Self::default().value), + } + } +} + +impl Default for ExpandedValueSuffix { + fn default() -> Self { + Self { + value: EXPANDED_VALUE_HEADER.to_string(), + } + } +} + +// --- + +fn styled(style: Style, text: &str) -> String { + if style == Style::reset() { + return text.into(); + } + + let mut buf = Vec::new(); + style.with(&mut buf, |buf| { + buf.extend(text.as_bytes()); + }); + String::from_utf8(buf).unwrap() +} + +// --- + #[cfg(test)] mod tests; diff --git a/src/themecfg/element.rs b/src/themecfg/element.rs index f205df340..81bb41792 100644 --- a/src/themecfg/element.rs +++ b/src/themecfg/element.rs @@ -36,6 +36,8 @@ pub enum Element { BooleanFalse, Null, Ellipsis, + Bullet, + ValueExpansion, } impl Element {