diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa144af..4f83f7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - [#789]: `defmt`: Add support for new time-related display hints +- [#783]: `defmt-decoder`: Move formatting logic to `Formatter` [#789]: https://github.com/knurling-rs/defmt/pull/789 +[#783]: https://github.com/knurling-rs/defmt/pull/783 ## defmt-decoder v0.3.9, defmt-print v0.3.10 - 2023-10-04 diff --git a/decoder/src/log/format/mod.rs b/decoder/src/log/format/mod.rs new file mode 100644 index 00000000..0f62dd37 --- /dev/null +++ b/decoder/src/log/format/mod.rs @@ -0,0 +1,727 @@ +use super::{DefmtRecord, Payload}; +use crate::Frame; +use colored::{Color, ColoredString, Colorize, Styles}; +use dissimilar::Chunk; +use log::{Level, Record as LogRecord}; +use std::{fmt::Write, path::Path}; + +mod parser; + +/// Representation of what a [LogSegment] can be. +#[derive(Debug, PartialEq, Clone)] +#[non_exhaustive] +pub(super) enum LogMetadata { + /// `{c}` format specifier. + /// + /// Prints the name of the crate where the log is coming from. + CrateName, + + /// `{f}` format specifier. + /// + /// This specifier may be repeated up to 255 times. + /// For a file "/path/to/crate/src/foo/bar.rs": + /// - `{f}` prints "bar.rs". + /// - `{ff}` prints "foo/bar.rs". + /// - `{fff}` prints "src/foo/bar.rs" + FileName(u8), + + /// `{F}` format specifier. + /// + /// For a file "/path/to/crate/src/foo/bar.rs" + /// this option prints "/path/to/crate/src/foo/bar.rs". + FilePath, + + /// `{l}` format specifier. + /// + /// Prints the line number where the log is coming from. + LineNumber, + + /// `{s}` format specifier. + /// + /// Prints the actual log contents. + /// For `defmt::info!("hello")`, this prints "hello". + Log, + + /// `{L}` format specifier. + /// + /// Prints the log level. + /// For `defmt::info!("hello")`, this prints "INFO". + LogLevel, + + /// `{m}` format specifier. + /// + /// Prints the module path of the function where the log is coming from. + /// For the following log: + /// + /// ```ignore + /// // crate: my_crate + /// mod foo { + /// fn bar() { + /// defmt::info!("hello"); + /// } + /// } + /// ``` + /// this prints "my_crate::foo::bar". + ModulePath, + + /// Represents the parts of the formatting string that is not specifiers. + String(String), + + /// `{t}` format specifier. + /// + /// Prints the timestamp at which something was logged. + /// For a log printed with a timestamp 123456 ms, this prints "123456". + Timestamp, + + /// Represents formats specified within nested curly brackets in the formatting string. + NestedLogSegments(Vec), +} + +impl LogMetadata { + /// Checks whether this `LogMetadata` came from a specifier such as + /// {t}, {f}, etc. + fn is_metadata_specifier(&self) -> bool { + !matches!( + self, + LogMetadata::String(_) | LogMetadata::NestedLogSegments(_) + ) + } +} + +/// Coloring options for [LogSegment]s. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(super) enum LogColor { + /// User-defined color. + /// + /// Use a string that can be parsed by the FromStr implementation + /// of [colored::Color]. + Color(colored::Color), + + /// Color matching the default color for the log level. + /// Use `"severity"` as a format parameter to use this option. + SeverityLevel, + + /// Color matching the default color for the log level, + /// but only if the log level is WARN or ERROR. + /// + /// Use `"werror"` as a format parameter to use this option. + WarnError, +} + +/// Alignment options for [LogSegment]s. +#[derive(Debug, PartialEq, Clone, Copy)] +pub(super) enum Alignment { + Center, + Left, + Right, +} + +#[derive(Debug, PartialEq, Clone, Copy)] +pub(super) enum Padding { + Space, + Zero, +} + +/// Representation of a segment of the formatting string. +#[derive(Debug, PartialEq, Clone)] +pub(super) struct LogSegment { + pub(super) metadata: LogMetadata, + pub(super) format: LogFormat, +} + +#[derive(Debug, PartialEq, Clone)] +pub(super) struct LogFormat { + pub(super) width: Option, + pub(super) color: Option, + pub(super) style: Option>, + pub(super) alignment: Option, + pub(super) padding: Option, +} + +impl LogSegment { + pub(super) const fn new(metadata: LogMetadata) -> Self { + Self { + metadata, + format: LogFormat { + color: None, + style: None, + width: None, + alignment: None, + padding: None, + }, + } + } + + #[cfg(test)] + pub(crate) const fn with_color(mut self, color: LogColor) -> Self { + self.format.color = Some(color); + self + } + + #[cfg(test)] + pub(crate) fn with_style(mut self, style: colored::Styles) -> Self { + let mut styles = self.format.style.unwrap_or_default(); + styles.push(style); + self.format.style = Some(styles); + self + } + + #[cfg(test)] + pub(crate) const fn with_width(mut self, width: usize) -> Self { + self.format.width = Some(width); + self + } + + #[cfg(test)] + pub(crate) const fn with_alignment(mut self, alignment: Alignment) -> Self { + self.format.alignment = Some(alignment); + self + } + + #[cfg(test)] + pub(crate) const fn with_padding(mut self, padding: Padding) -> Self { + self.format.padding = Some(padding); + self + } +} + +pub struct Formatter { + formatter: InternalFormatter, +} + +impl Formatter { + pub fn new(config: FormatterConfig) -> Self { + Self { + formatter: InternalFormatter::new(config, Source::Defmt), + } + } + + pub fn format_frame<'a>( + &self, + frame: Frame<'a>, + file: Option<&'a str>, + line: Option, + module_path: Option<&str>, + ) -> String { + let (timestamp, level) = super::timestamp_and_level_from_frame(&frame); + + // HACK: use match instead of let, because otherwise compilation fails + #[allow(clippy::match_single_binding)] + match format_args!("{}", frame.display_message()) { + args => { + let log_record = &LogRecord::builder() + .args(args) + .module_path(module_path) + .file(file) + .line(line) + .build(); + + let record = DefmtRecord { + log_record, + payload: Payload { level, timestamp }, + }; + + self.format(&record) + } + } + } + + pub(super) fn format(&self, record: &DefmtRecord) -> String { + self.formatter.format(&Record::Defmt(record)) + } +} + +pub struct HostFormatter { + formatter: InternalFormatter, +} + +impl HostFormatter { + pub fn new(config: FormatterConfig) -> Self { + Self { + formatter: InternalFormatter::new(config, Source::Host), + } + } + + pub fn format(&self, record: &LogRecord) -> String { + self.formatter.format(&Record::Host(record)) + } +} + +#[derive(Debug)] +struct InternalFormatter { + format: Vec, +} + +#[derive(Clone, Copy, PartialEq)] +enum Source { + Defmt, + Host, +} + +enum Record<'a> { + Defmt(&'a DefmtRecord<'a>), + Host(&'a LogRecord<'a>), +} + +#[derive(Debug)] +pub enum FormatterFormat<'a> { + Default { with_location: bool }, + Custom(&'a str), +} + +impl Default for FormatterFormat<'_> { + fn default() -> Self { + FormatterFormat::Default { + with_location: false, + } + } +} + +#[derive(Debug, Default)] +pub struct FormatterConfig<'a> { + pub format: FormatterFormat<'a>, + pub is_timestamp_available: bool, +} + +impl<'a> FormatterConfig<'a> { + pub fn custom(format: &'a str) -> Self { + FormatterConfig { + format: FormatterFormat::Custom(format), + is_timestamp_available: false, + } + } + + pub fn with_timestamp(mut self) -> Self { + self.is_timestamp_available = true; + self + } + + pub fn with_location(mut self) -> Self { + // TODO: Should we warn the user that trying to set a location + // for a custom format won't work? + match self.format { + FormatterFormat::Default { with_location: _ } => { + self.format = FormatterFormat::Default { + with_location: true, + }; + self + } + _ => self, + } + } +} + +impl InternalFormatter { + fn new(config: FormatterConfig, source: Source) -> Self { + const FORMAT: &str = "{L} {s}"; + const FORMAT_WITH_LOCATION: &str = "{L} {s}\n└─ {m} @ {F}:{l}"; + const FORMAT_WITH_TIMESTAMP: &str = "{t} {L} {s}"; + const FORMAT_WITH_TIMESTAMP_AND_LOCATION: &str = "{t} {L} {s}\n└─ {m} @ {F}:{l}"; + + let format = match config.format { + FormatterFormat::Default { with_location } => { + let mut format = match (with_location, config.is_timestamp_available) { + (false, false) => FORMAT, + (false, true) => FORMAT_WITH_TIMESTAMP, + (true, false) => FORMAT_WITH_LOCATION, + (true, true) => FORMAT_WITH_TIMESTAMP_AND_LOCATION, + } + .to_string(); + + if source == Source::Host { + format.insert_str(0, "(HOST) "); + } + + format + } + FormatterFormat::Custom(format) => format.to_string(), + }; + + let format = parser::parse(&format).expect("log format is invalid '{format}'"); + + if matches!(config.format, FormatterFormat::Custom(_)) { + let format_has_timestamp = format_has_timestamp(&format); + if format_has_timestamp && !config.is_timestamp_available { + log::warn!( + "logger format contains timestamp but no timestamp implementation \ + was provided; consider removing the timestamp (`{{t}}` or `{{T}}`) from the \ + logger format or provide a `defmt::timestamp!` implementation" + ); + } else if !format_has_timestamp && config.is_timestamp_available { + log::warn!( + "`defmt::timestamp!` implementation was found, but timestamp is not \ + part of the log format; consider adding the timestamp (`{{t}}` or `{{T}}`) \ + argument to the log format" + ); + } + } + + Self { format } + } + + fn format(&self, record: &Record) -> String { + let mut buf = String::new(); + for segment in &self.format { + let s = self.build_segment(record, segment); + write!(buf, "{s}").expect("writing to String cannot fail"); + } + buf + } + + fn build_segment(&self, record: &Record, segment: &LogSegment) -> String { + match &segment.metadata { + LogMetadata::String(s) => s.to_string(), + LogMetadata::Timestamp => self.build_timestamp(record, &segment.format), + LogMetadata::CrateName => self.build_crate_name(record, &segment.format), + LogMetadata::FileName(n) => self.build_file_name(record, &segment.format, *n), + LogMetadata::FilePath => self.build_file_path(record, &segment.format), + LogMetadata::ModulePath => self.build_module_path(record, &segment.format), + LogMetadata::LineNumber => self.build_line_number(record, &segment.format), + LogMetadata::LogLevel => self.build_log_level(record, &segment.format), + LogMetadata::Log => self.build_log(record, &segment.format), + LogMetadata::NestedLogSegments(segments) => { + self.build_nested(record, segments, &segment.format) + } + } + } + + fn build_nested(&self, record: &Record, segments: &[LogSegment], format: &LogFormat) -> String { + let mut result = String::new(); + for segment in segments { + let s = match &segment.metadata { + LogMetadata::String(s) => s.to_string(), + LogMetadata::Timestamp => self.build_timestamp(record, &segment.format), + LogMetadata::CrateName => self.build_crate_name(record, &segment.format), + LogMetadata::FileName(n) => self.build_file_name(record, &segment.format, *n), + LogMetadata::FilePath => self.build_file_path(record, &segment.format), + LogMetadata::ModulePath => self.build_module_path(record, &segment.format), + LogMetadata::LineNumber => self.build_line_number(record, &segment.format), + LogMetadata::LogLevel => self.build_log_level(record, &segment.format), + LogMetadata::Log => self.build_log(record, &segment.format), + LogMetadata::NestedLogSegments(segments) => { + self.build_nested(record, segments, &segment.format) + } + }; + result.push_str(&s); + } + + build_formatted_string( + &result, + format, + 0, + get_log_level_of_record(record), + format.color, + ) + } + + fn build_timestamp(&self, record: &Record, format: &LogFormat) -> String { + let s = match record { + Record::Defmt(record) if !record.timestamp().is_empty() => record.timestamp(), + _ => "