733 lines
24 KiB
Rust
733 lines
24 KiB
Rust
use crate::diagnostics::{Diagnostic, Severity};
|
|
use crate::lexer::{TokenSpan, TokenizedFile};
|
|
|
|
use core::convert::Into;
|
|
use crossterm::style::Stylize;
|
|
use std::cmp::max;
|
|
use std::collections::HashMap;
|
|
use std::ops::RangeInclusive;
|
|
|
|
const INDENT: &str = " ";
|
|
/*
|
|
error[P0034]: missing `{` to start `switch` body
|
|
in file: files/P0034_04.uc
|
|
1 | ╭switch
|
|
| │------ `switch` starts here
|
|
2 | ╭ │(
|
|
3 | │ │ A
|
|
4 | │ │)
|
|
| │ ╰─^ expected `{` before end of file
|
|
| ╰ ^ switch selector is here
|
|
|
|
|
|
|
|
|
|
Outmost guideline isn't reaching ^!. And should it be `^` even?
|
|
*/
|
|
/*
|
|
error: expected one of `,`, `:`, or `}`, found `token_to`
|
|
--> rottlib/src/ast/mod.rs:80:13
|
|
|
|
|
78 | Self {
|
|
| ---- while parsing this struct
|
|
79 | token_from: self.token_from,scd
|
|
| --- while parsing this struct field
|
|
80 | token_to: std::cmp::max(self.token_to, right_most_index),
|
|
| ^^^^^^^^ expected one of `,`, `:`, or `}`
|
|
*/
|
|
|
|
/*
|
|
|
|
|
76 | / "asdasdas
|
|
77 | | asd1
|
|
78 | | asd2
|
|
79 | | asdasd"
|
|
| |___________________^ expected `()`, found `&str`
|
|
*/
|
|
|
|
/*
|
|
1. Get each span's range and total lines covered by spans as ranges;
|
|
2. We need `+N` more lines for `N` labels;
|
|
3.
|
|
*/
|
|
|
|
|
|
// TODO: check if blue guidelines are sometimes red or vice versa
|
|
// TODO: tabs needs to be replaced with 1-width character
|
|
// These are abstract rendering events, not self-contained draw commands.
|
|
// They are emitted in increasing order of "significant lines" (range starts/ends).
|
|
// The actual source span for a label is recovered later from its LabelType.
|
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
|
enum RendererCommands {
|
|
StartRange {
|
|
label_type: LabelType,
|
|
column: usize,
|
|
},
|
|
FinishRange {
|
|
label_type: LabelType,
|
|
column: usize,
|
|
},
|
|
SingleRange {
|
|
label_type: LabelType,
|
|
},
|
|
}
|
|
|
|
enum LineIndexType {
|
|
Normal(usize),
|
|
Missing,
|
|
Ellipsis,
|
|
}
|
|
|
|
// Label ordering is semantic: primary first, then secondaries in diagnostic order.
|
|
// That order is also used to break visual ties when multiple labels would otherwise
|
|
// start or end on the same source line.
|
|
#[derive(PartialEq, Eq, Hash, Clone, Copy)]
|
|
enum LabelType {
|
|
Primary,
|
|
Secondary(usize),
|
|
}
|
|
|
|
struct RangeSet {
|
|
primary_range: Option<RangeInclusive<usize>>,
|
|
secondary_ranges: Vec<RangeInclusive<usize>>,
|
|
}
|
|
|
|
impl RangeSet {
|
|
/*fn get(&self, index: usize) -> Option<&RangeInclusive<usize>> {
|
|
if self.primary_range.is_some() {
|
|
if index == 0 {
|
|
return self.primary_range.as_ref();
|
|
} else {
|
|
self.secondary_ranges.get(index - 1)
|
|
}
|
|
} else {
|
|
self.secondary_ranges.get(index)
|
|
}
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
self.secondary_ranges.len() + if self.primary_range.is_some() { 1 } else { 0 }
|
|
}*/
|
|
|
|
fn iter(&self) -> impl Iterator<Item = &RangeInclusive<usize>> {
|
|
self.primary_range
|
|
.iter()
|
|
.chain(self.secondary_ranges.iter())
|
|
}
|
|
|
|
fn iter_labeled(&self) -> impl Iterator<Item = (LabelType, &RangeInclusive<usize>)> {
|
|
self.primary_range
|
|
.iter()
|
|
.map(|range| (LabelType::Primary, range))
|
|
.chain(
|
|
self.secondary_ranges
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, range)| (LabelType::Secondary(index), range)),
|
|
)
|
|
}
|
|
|
|
fn get_first_bound_above(&self, line_number: Option<usize>) -> Option<usize> {
|
|
self.iter()
|
|
.filter_map(|range| {
|
|
let start = *range.start();
|
|
let end = *range.end();
|
|
|
|
let start_ok = line_number.is_none_or(|n| start > n).then_some(start);
|
|
let end_ok = line_number.is_none_or(|n| end > n).then_some(end);
|
|
|
|
match (start_ok, end_ok) {
|
|
(Some(a), Some(b)) => Some(a.min(b)),
|
|
(Some(a), None) => Some(a),
|
|
(None, Some(b)) => Some(b),
|
|
(None, None) => None,
|
|
}
|
|
})
|
|
.min()
|
|
}
|
|
}
|
|
|
|
// Converts labeled line ranges into an ordered stream of renderer events.
|
|
//
|
|
// Important invariants:
|
|
//
|
|
// 1. Commands are ordered by increasing significant line.
|
|
// A significant line is any line on which some label starts or ends.
|
|
//
|
|
// 2. If multiple labels would visually terminate on the same source line,
|
|
// the renderer treats them as ending on distinct phantom rows, ordered by
|
|
// diagnostic priority (primary/secondary order). This prevents intersections
|
|
// and means that same-line closings are intentionally linearized rather than
|
|
// treated as a geometric tie.
|
|
//
|
|
// 3. RendererCommands do not store source line numbers directly.
|
|
// Later rendering recovers the underlying span from LabelType and uses the
|
|
// event order to know when labels become active/inactive.
|
|
//
|
|
// 4. When a label starts on the same significant line where another label ends,
|
|
// starts are processed first. This is intentional: longer-lived/opening labels
|
|
// must occupy earlier columns so that shorter-lived/closing labels bend around
|
|
// them without intersecting.
|
|
fn make_renderer_commands(ranges: RangeSet) -> Vec<(usize, RendererCommands)> {
|
|
// Maps currently-open labels to the index of their StartRange command so that
|
|
// we can patch in the final column once the label closes.
|
|
let mut open_ranges = HashMap::new();
|
|
let mut commands = Vec::new();
|
|
let mut current_line = None;
|
|
while let Some(next_significant_line) = ranges.get_first_bound_above(current_line) {
|
|
current_line = Some(next_significant_line);
|
|
// First process all new ranges because they'll live longer and have
|
|
// to have earlier columns
|
|
for (label, range) in ranges.iter_labeled() {
|
|
if *range.start() == next_significant_line {
|
|
if range.start() != range.end() {
|
|
commands.push((
|
|
*range.start(),
|
|
RendererCommands::StartRange {
|
|
label_type: label,
|
|
column: 0,
|
|
},
|
|
));
|
|
open_ranges.insert(label, commands.len() - 1);
|
|
} else {
|
|
commands.push((
|
|
*range.start(),
|
|
RendererCommands::SingleRange { label_type: label },
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Closing pass.
|
|
// The assigned column is the number of ranges that remain open after removing
|
|
// this label. Because same-line visual ties are already linearized by label
|
|
// priority / phantom rows, processing labels in iter_labeled() order is
|
|
// intentional here.
|
|
for (label, range) in ranges.iter_labeled() {
|
|
if *range.end() == next_significant_line {
|
|
if let Some(index) = open_ranges.remove(&label) {
|
|
// Column meaning:
|
|
// 0 = outermost / earliest lane
|
|
// larger values = further inward lanes
|
|
//
|
|
// We assign the column at close time, not at open time, because the final lane
|
|
// depends on which other ranges outlive this one.
|
|
let column = open_ranges.len();
|
|
if let Some((line_number, RendererCommands::StartRange { .. })) =
|
|
commands.get(index)
|
|
{
|
|
commands[index] = (
|
|
*line_number,
|
|
RendererCommands::StartRange {
|
|
label_type: label,
|
|
column,
|
|
},
|
|
);
|
|
}
|
|
commands.push((
|
|
*range.end(),
|
|
RendererCommands::FinishRange {
|
|
label_type: label,
|
|
column,
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
commands
|
|
}
|
|
|
|
fn max_line_number_width(ranges: &RangeSet) -> usize {
|
|
let max_line = ranges.iter().map(|range| *range.end()).max().unwrap_or(0);
|
|
|
|
if max_line == 0 {
|
|
1
|
|
} else {
|
|
max_line.ilog10() as usize + 1
|
|
}
|
|
}
|
|
|
|
fn span_to_range<'src>(
|
|
span: TokenSpan,
|
|
file: &TokenizedFile<'src>,
|
|
) -> Option<RangeInclusive<usize>> {
|
|
let start_line = file.token_line(span.start)?;
|
|
let end_line = file.token_line(span.end)?;
|
|
|
|
if start_line <= end_line {
|
|
Some(start_line..=end_line)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn make_ranges<'src>(file: &TokenizedFile<'src>, diagnostic: &Diagnostic) -> RangeSet {
|
|
let mut result = RangeSet {
|
|
primary_range: None,
|
|
secondary_ranges: Vec::new(),
|
|
};
|
|
result.primary_range = diagnostic
|
|
.primary_label()
|
|
.and_then(|label| span_to_range(label.span, file));
|
|
for secondary in diagnostic.secondary_labels() {
|
|
if let Some(range) = span_to_range(secondary.span, file) {
|
|
result.secondary_ranges.push(range);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
impl Diagnostic {
|
|
pub fn render<'src>(&self, file: &TokenizedFile<'src>, file_path: impl Into<String>) {
|
|
self.render_header();
|
|
println!("{INDENT}{}: {}", "in file".blue().bold(), file_path.into());
|
|
self.render_lines(file);
|
|
self.render_help_and_notes(file);
|
|
}
|
|
/*StartRange {
|
|
label_type: LabelType,
|
|
column: usize,
|
|
},
|
|
FinishRange {
|
|
label_type: LabelType,
|
|
},
|
|
SingleRange {
|
|
label_type: LabelType,
|
|
}, */
|
|
fn label_data(&self, label_type: LabelType) -> Option<(TokenSpan, String)> {
|
|
match label_type {
|
|
LabelType::Primary => self
|
|
.primary_label()
|
|
.map(|label| (label.span, label.message.clone())),
|
|
LabelType::Secondary(id) => Some((
|
|
self.secondary_labels()[id].span,
|
|
self.secondary_labels()[id].message.clone(),
|
|
)),
|
|
}
|
|
}
|
|
fn render_lines<'src>(&self, file: &TokenizedFile<'src>) {
|
|
let ranges = make_ranges(file, &self);
|
|
let max_line_number_width = max(max_line_number_width(&ranges), 3);
|
|
let commands = make_renderer_commands(ranges);
|
|
let mut max_column = 0;
|
|
for command in &commands {
|
|
if let (_, RendererCommands::StartRange { column, .. }) = command {
|
|
max_column = max(max_column, *column);
|
|
}
|
|
}
|
|
let mut vertical_stack = Vec::new();
|
|
vertical_stack.resize(max_column + 1, None);
|
|
|
|
let mut i = 0;
|
|
while i < commands.len() {
|
|
let mut current_line = commands[i].0;
|
|
let mut single_commands = Vec::new();
|
|
let mut start_commands = Vec::new();
|
|
let mut finish_commands = Vec::new();
|
|
while i < commands.len() && current_line == commands[i].0 {
|
|
match commands[i].1 {
|
|
RendererCommands::SingleRange { label_type } => {
|
|
single_commands.push(label_type)
|
|
}
|
|
RendererCommands::StartRange { label_type, column } => {
|
|
start_commands.push((label_type, column));
|
|
}
|
|
RendererCommands::FinishRange { label_type, column } => {
|
|
finish_commands.push((label_type, column))
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
// !!!!!!!!!!!!!!!!
|
|
// First - update line drawing stack
|
|
// First - update line drawing stack
|
|
for &(label_type, column) in &start_commands {
|
|
vertical_stack[column] = Some(label_type);
|
|
}
|
|
// Next - draw the line
|
|
self.draw_line_with_starts(
|
|
current_line,
|
|
max_line_number_width,
|
|
file,
|
|
&vertical_stack,
|
|
&start_commands,
|
|
);
|
|
for label_type in single_commands {
|
|
self.render_single_command(
|
|
label_type,
|
|
max_line_number_width,
|
|
file,
|
|
&vertical_stack,
|
|
);
|
|
}
|
|
// Next - render finish commands (drop for now)
|
|
for (label_type, column) in finish_commands {
|
|
self.render_finish_command(
|
|
label_type,
|
|
max_line_number_width,
|
|
file,
|
|
&vertical_stack,
|
|
);
|
|
vertical_stack[column] = None;
|
|
}
|
|
// !!!!!!!!!!!!!!!!
|
|
// Render some more lines
|
|
let mut countdown = 3;
|
|
current_line += 1;
|
|
while i < commands.len() && current_line < commands[i].0 {
|
|
if countdown == 0 {
|
|
if current_line + 1 == commands[i].0 {
|
|
self.draw_line(current_line, max_line_number_width, file, &vertical_stack);
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
self.make_line_prefix(
|
|
LineIndexType::Ellipsis,
|
|
max_line_number_width,
|
|
&vertical_stack
|
|
)
|
|
);
|
|
}
|
|
break;
|
|
} else {
|
|
self.draw_line(current_line, max_line_number_width, file, &vertical_stack);
|
|
}
|
|
current_line += 1;
|
|
countdown -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_single_command<'src>(
|
|
&self,
|
|
label_type: LabelType,
|
|
max_line_number_width: usize,
|
|
file: &TokenizedFile<'src>,
|
|
vertical_stack: &[Option<LabelType>],
|
|
) {
|
|
let Some((span, message)) = self.label_data(label_type) else {
|
|
return;
|
|
};
|
|
let Some(visible) = file.span_visible_on_line(span) else {
|
|
return;
|
|
};
|
|
|
|
let mut builder = self.make_line_prefix(
|
|
LineIndexType::Missing,
|
|
max_line_number_width,
|
|
vertical_stack,
|
|
);
|
|
|
|
builder.push_str(&" ".repeat(visible.columns.start));
|
|
|
|
let underline_width = (visible.columns.end - visible.columns.start).max(1);
|
|
let mut underline_label = if label_type == LabelType::Primary {
|
|
"^".repeat(underline_width)
|
|
} else {
|
|
"-".repeat(underline_width)
|
|
};
|
|
underline_label.push_str(&format!(" {}", message));
|
|
|
|
match label_type {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
builder.push_str(&underline_label.red().bold().to_string());
|
|
} else {
|
|
builder.push_str(&underline_label.yellow().bold().to_string());
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => {
|
|
builder.push_str(&underline_label.blue().bold().to_string());
|
|
}
|
|
}
|
|
|
|
println!("{builder}");
|
|
}
|
|
|
|
fn render_finish_command<'src>(
|
|
&self,
|
|
label_type: LabelType,
|
|
max_line_number_width: usize,
|
|
file: &TokenizedFile<'src>,
|
|
vertical_stack: &[Option<LabelType>],
|
|
) {
|
|
let Some((span, message)) = self.label_data(label_type) else {
|
|
return;
|
|
};
|
|
|
|
let Some(visible) = file
|
|
.token_visible_spans(span.end)
|
|
.and_then(|spans| spans.into_iter().last())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let mut builder =
|
|
self.make_finish_prefix(max_line_number_width, vertical_stack, label_type);
|
|
|
|
builder.push_str(&"─".repeat(visible.columns.start).red().to_string());
|
|
|
|
let underline_width = (visible.columns.end - visible.columns.start).max(1);
|
|
let mut underline_label = "^".repeat(underline_width);
|
|
underline_label.push_str(&format!(" {}", message));
|
|
|
|
match label_type {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
builder.push_str(&underline_label.red().bold().to_string());
|
|
} else {
|
|
builder.push_str(&underline_label.yellow().bold().to_string());
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => {
|
|
builder.push_str(&underline_label.blue().bold().to_string());
|
|
}
|
|
}
|
|
|
|
println!("{builder}");
|
|
}
|
|
|
|
fn draw_line<'src>(
|
|
&self,
|
|
current_line: usize,
|
|
max_line_number_width: usize,
|
|
file: &TokenizedFile<'src>,
|
|
vertical_stack: &[Option<LabelType>],
|
|
) {
|
|
println!(
|
|
"{}{}",
|
|
self.make_line_prefix(
|
|
LineIndexType::Normal(current_line),
|
|
max_line_number_width,
|
|
vertical_stack
|
|
),
|
|
file.line_text(current_line).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
fn draw_line_with_starts<'src>(
|
|
&self,
|
|
current_line: usize,
|
|
max_line_number_width: usize,
|
|
file: &TokenizedFile<'src>,
|
|
vertical_stack: &[Option<LabelType>],
|
|
start_commands: &[(LabelType, usize)],
|
|
) {
|
|
println!(
|
|
"{}{}",
|
|
self.make_start_prefix(
|
|
LineIndexType::Normal(current_line),
|
|
max_line_number_width,
|
|
vertical_stack,
|
|
start_commands,
|
|
),
|
|
file.line_text(current_line).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
fn make_line_prefix<'src>(
|
|
&self,
|
|
current_line: LineIndexType,
|
|
max_line_number_width: usize,
|
|
vertical_stack: &[Option<LabelType>],
|
|
) -> String {
|
|
let line_text = match current_line {
|
|
LineIndexType::Normal(current_line) => (current_line + 1).to_string(),
|
|
LineIndexType::Missing => "".to_string(),
|
|
LineIndexType::Ellipsis => "...".to_string(),
|
|
};
|
|
let line_padding = " ".repeat(max_line_number_width - line_text.len());
|
|
let mut builder = format!(" {}{} | ", line_padding, line_text)
|
|
.blue()
|
|
.bold()
|
|
.to_string();
|
|
|
|
for vertical_line in vertical_stack {
|
|
if let Some(label) = vertical_line {
|
|
let piece = match label {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
" │".red()
|
|
} else {
|
|
" │".yellow()
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => " │".blue(),
|
|
}
|
|
.to_string();
|
|
builder.push_str(&piece);
|
|
} else {
|
|
builder.push_str(" ");
|
|
}
|
|
}
|
|
builder
|
|
}
|
|
|
|
fn make_start_prefix(
|
|
&self,
|
|
current_line: LineIndexType,
|
|
max_line_number_width: usize,
|
|
vertical_stack: &[Option<LabelType>],
|
|
start_commands: &[(LabelType, usize)],
|
|
) -> String {
|
|
let line_text = match current_line {
|
|
LineIndexType::Normal(current_line) => (current_line + 1).to_string(),
|
|
LineIndexType::Missing => "".to_string(),
|
|
LineIndexType::Ellipsis => "...".to_string(),
|
|
};
|
|
let line_padding = " ".repeat(max_line_number_width - line_text.len());
|
|
let mut builder = format!(" {}{} | ", line_padding, line_text)
|
|
.blue()
|
|
.bold()
|
|
.to_string();
|
|
|
|
for (column, vertical_line) in vertical_stack.iter().enumerate() {
|
|
let piece = match vertical_line {
|
|
Some(label) => {
|
|
let starts_here = start_commands.iter().any(|(start_label, start_column)| {
|
|
*start_label == *label && *start_column == column
|
|
});
|
|
|
|
match label {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
if starts_here {
|
|
" ╭".red()
|
|
} else {
|
|
" │".red()
|
|
}
|
|
} else {
|
|
if starts_here {
|
|
" ╭".yellow()
|
|
} else {
|
|
" │".yellow()
|
|
}
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => {
|
|
if starts_here {
|
|
" ╭".blue()
|
|
} else {
|
|
" │".blue()
|
|
}
|
|
}
|
|
}
|
|
.to_string()
|
|
}
|
|
None => " ".to_string(),
|
|
};
|
|
|
|
builder.push_str(&piece);
|
|
}
|
|
|
|
builder
|
|
}
|
|
|
|
fn make_finish_prefix(
|
|
&self,
|
|
max_line_number_width: usize,
|
|
vertical_stack: &[Option<LabelType>],
|
|
finishing_label: LabelType,
|
|
) -> String {
|
|
let line_text = "";
|
|
let line_padding = " ".repeat(max_line_number_width - line_text.len());
|
|
let mut builder = format!(" {}{} | ", line_padding, line_text)
|
|
.blue()
|
|
.bold()
|
|
.to_string();
|
|
|
|
for vertical_line in vertical_stack {
|
|
let piece = match vertical_line {
|
|
Some(label) if *label == finishing_label => match label {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
" ╰".red()
|
|
} else {
|
|
" ╰".yellow()
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => " ╰".blue(),
|
|
}
|
|
.to_string(),
|
|
|
|
Some(label) => match label {
|
|
LabelType::Primary => {
|
|
if self.severity == Severity::Error {
|
|
" │".red()
|
|
} else {
|
|
" │".yellow()
|
|
}
|
|
}
|
|
LabelType::Secondary(_) => " │".blue(),
|
|
}
|
|
.to_string(),
|
|
|
|
None => " ".to_string(),
|
|
};
|
|
|
|
builder.push_str(&piece);
|
|
}
|
|
|
|
builder
|
|
}
|
|
|
|
fn render_header(&self) {
|
|
let severity_label = match self.severity {
|
|
Severity::Error => "error".red(),
|
|
Severity::Warning => "warning".yellow(),
|
|
};
|
|
if let Some(ref code) = self.code {
|
|
println!(
|
|
"{}",
|
|
format!("{}[{}]: {}", severity_label, code, self.headline).bold()
|
|
);
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
format!("{}: {}", severity_label, self.headline).bold()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn render_help_and_notes<'src>(&self, file: &TokenizedFile<'src>) {
|
|
if self.help().is_none() && self.notes().is_empty() {
|
|
return;
|
|
}
|
|
|
|
let ranges = make_ranges(file, self);
|
|
let max_line_number_width = max(max_line_number_width(&ranges), 3);
|
|
|
|
// Blank gutter separator, like rustc's trailing `|`
|
|
println!(
|
|
"{}",
|
|
format!(" {} |", " ".repeat(max_line_number_width))
|
|
.blue()
|
|
.bold()
|
|
);
|
|
|
|
if let Some(help) = self.help() {
|
|
self.render_trailer_line("help", help, max_line_number_width);
|
|
}
|
|
|
|
for note in self.notes() {
|
|
self.render_trailer_line("note", note, max_line_number_width);
|
|
}
|
|
}
|
|
|
|
fn render_trailer_line(&self, kind: &str, message: &str, max_line_number_width: usize) {
|
|
let prefix = format!(" {} = ", " ".repeat(max_line_number_width))
|
|
.blue()
|
|
.bold()
|
|
.to_string();
|
|
|
|
let kind = match kind {
|
|
"help" => "help".green().bold().to_string(),
|
|
"note" => "note".blue().bold().to_string(),
|
|
_ => kind.bold().to_string(),
|
|
};
|
|
|
|
println!("{prefix}{kind}: {message}");
|
|
}
|
|
}
|