dprint_cli_core/logging/
logger.rs

1use crossterm::cursor;
2use crossterm::style;
3use crossterm::terminal;
4use crossterm::QueueableCommand;
5use parking_lot::Mutex;
6use std::io::stderr;
7use std::io::stdout;
8use std::io::Stderr;
9use std::io::Stdout;
10use std::io::Write;
11use std::sync::Arc;
12
13pub enum LoggerTextItem {
14  Text(String),
15  HangingText { text: String, indent: u16 },
16}
17
18#[derive(PartialOrd, Ord, PartialEq, Eq)]
19pub(crate) enum LoggerRefreshItemKind {
20  // numbered by display order
21  ProgressBars = 0,
22  Selection = 1,
23}
24
25struct LoggerRefreshItem {
26  kind: LoggerRefreshItemKind,
27  text_items: Vec<LoggerTextItem>,
28}
29
30#[derive(Clone)]
31pub struct LoggerOptions {
32  pub initial_context_name: String,
33  /// Whether stdout will be read by a program.
34  pub is_stdout_machine_readable: bool,
35}
36
37#[derive(Clone)]
38pub struct Logger {
39  output_lock: Arc<Mutex<LoggerState>>,
40  is_stdout_machine_readable: bool,
41}
42
43struct LoggerState {
44  last_context_name: String,
45  std_out: Stdout,
46  std_err: Stderr,
47  refresh_items: Vec<LoggerRefreshItem>,
48  last_terminal_size: Option<(u16, u16)>,
49}
50
51impl Logger {
52  pub fn new(options: &LoggerOptions) -> Self {
53    Logger {
54      output_lock: Arc::new(Mutex::new(LoggerState {
55        last_context_name: options.initial_context_name.clone(),
56        std_out: stdout(),
57        std_err: stderr(),
58        refresh_items: Vec::new(),
59        last_terminal_size: None,
60      })),
61      is_stdout_machine_readable: options.is_stdout_machine_readable,
62    }
63  }
64
65  pub fn log(&self, text: &str, context_name: &str) {
66    if self.is_stdout_machine_readable {
67      return;
68    }
69    let mut state = self.output_lock.lock();
70    self.inner_log(&mut state, true, text, context_name);
71  }
72
73  pub fn log_machine_readable(&self, text: &str) {
74    let mut state = self.output_lock.lock();
75    let last_context_name = state.last_context_name.clone(); // not really used here
76    self.inner_log(&mut state, true, text, &last_context_name);
77  }
78
79  pub fn log_err(&self, text: &str, context_name: &str) {
80    let mut state = self.output_lock.lock();
81    self.inner_log(&mut state, false, text, context_name);
82  }
83
84  pub fn log_text_items(&self, text_items: &[LoggerTextItem], context_name: &str, terminal_width: Option<u16>) {
85    let text = render_text_items_with_width(text_items, terminal_width);
86    self.log(&text, context_name);
87  }
88
89  fn inner_log(&self, state: &mut LoggerState, is_std_out: bool, text: &str, context_name: &str) {
90    if !state.refresh_items.is_empty() {
91      self.inner_queue_clear_previous_draws(state);
92    }
93
94    let mut output_text = String::new();
95    if state.last_context_name != context_name {
96      // don't output this if stdout is machine readable
97      if !is_std_out || !self.is_stdout_machine_readable {
98        output_text.push_str(&format!("[{}]\n", context_name));
99      }
100      state.last_context_name = context_name.to_string();
101    }
102
103    output_text.push_str(text);
104
105    // only add a newline if the logged text does not end with one
106    if !output_text.ends_with('\n') {
107      output_text.push('\n');
108    }
109
110    if is_std_out {
111      state.std_out.queue(style::Print(output_text)).unwrap();
112    } else {
113      state.std_err.queue(style::Print(output_text)).unwrap();
114    }
115
116    if !state.refresh_items.is_empty() {
117      self.inner_queue_draw_items(state);
118    }
119
120    if is_std_out {
121      state.std_out.flush().unwrap();
122      if !state.refresh_items.is_empty() {
123        state.std_err.flush().unwrap();
124      }
125    } else {
126      state.std_err.flush().unwrap();
127    }
128  }
129
130  pub(crate) fn set_refresh_item(&self, kind: LoggerRefreshItemKind, text_items: Vec<LoggerTextItem>) {
131    self.with_update_refresh_items(move |refresh_items| match refresh_items.binary_search_by(|i| i.kind.cmp(&kind)) {
132      Ok(pos) => {
133        let mut refresh_item = refresh_items.get_mut(pos).unwrap();
134        refresh_item.text_items = text_items;
135      }
136      Err(pos) => {
137        let refresh_item = LoggerRefreshItem { kind, text_items };
138        refresh_items.insert(pos, refresh_item);
139      }
140    });
141  }
142
143  pub(crate) fn remove_refresh_item(&self, kind: LoggerRefreshItemKind) {
144    self.with_update_refresh_items(move |refresh_items| {
145      if let Ok(pos) = refresh_items.binary_search_by(|i| i.kind.cmp(&kind)) {
146        refresh_items.remove(pos);
147      } else {
148        // already removed
149      }
150    });
151  }
152
153  fn with_update_refresh_items(&self, update_refresh_items: impl FnOnce(&mut Vec<LoggerRefreshItem>)) {
154    let mut state = self.output_lock.lock();
155
156    // hide the cursor if showing a refresh item for the first time
157    if state.refresh_items.is_empty() {
158      state.std_err.queue(cursor::Hide).unwrap();
159    }
160
161    self.inner_queue_clear_previous_draws(&mut state);
162
163    update_refresh_items(&mut state.refresh_items);
164
165    self.inner_queue_draw_items(&mut state);
166
167    // show the cursor if no longer showing a refresh item
168    if state.refresh_items.is_empty() {
169      state.std_err.queue(cursor::Show).unwrap();
170    }
171    state.std_err.flush().unwrap();
172  }
173
174  fn inner_queue_clear_previous_draws(&self, state: &mut LoggerState) {
175    let terminal_width = crate::terminal::get_terminal_width().unwrap();
176    let text_items = state.refresh_items.iter().flat_map(|item| item.text_items.iter());
177    let rendered_text = render_text_items_truncated_to_height(text_items, state.last_terminal_size);
178    let last_line_count = get_text_line_count(&rendered_text, terminal_width);
179
180    if last_line_count > 0 {
181      if last_line_count > 1 {
182        state.std_err.queue(cursor::MoveUp(last_line_count - 1)).unwrap();
183      }
184      state.std_err.queue(cursor::MoveToColumn(0)).unwrap();
185      state.std_err.queue(terminal::Clear(terminal::ClearType::FromCursorDown)).unwrap();
186    }
187
188    state.std_err.queue(cursor::MoveToColumn(0)).unwrap();
189  }
190
191  fn inner_queue_draw_items(&self, state: &mut LoggerState) {
192    let terminal_size = crate::terminal::get_terminal_size();
193    let text_items = state.refresh_items.iter().flat_map(|item| item.text_items.iter());
194    let rendered_text = render_text_items_truncated_to_height(text_items, terminal_size);
195    state.std_err.queue(style::Print(&rendered_text)).unwrap();
196    state.std_err.queue(cursor::MoveToColumn(0)).unwrap();
197    state.last_terminal_size = terminal_size;
198  }
199}
200
201/// Renders the text items with the specified width.
202pub fn render_text_items_with_width(text_items: &[LoggerTextItem], terminal_width: Option<u16>) -> String {
203  render_text_items_to_lines(text_items.iter(), terminal_width).join("\n")
204}
205
206fn render_text_items_truncated_to_height<'a>(text_items: impl Iterator<Item = &'a LoggerTextItem>, terminal_size: Option<(u16, u16)>) -> String {
207  let lines = render_text_items_to_lines(text_items, terminal_size.map(|(width, _)| width));
208  if let Some(height) = terminal_size.map(|(_, height)| height) {
209    let max_height = height as usize;
210    if lines.len() > max_height {
211      return lines[lines.len() - max_height..].join("\n");
212    }
213  }
214  lines.join("\n")
215}
216
217fn render_text_items_to_lines<'a>(text_items: impl Iterator<Item = &'a LoggerTextItem>, terminal_width: Option<u16>) -> Vec<String> {
218  let mut result = Vec::new();
219  for (_, item) in text_items.enumerate() {
220    match item {
221      LoggerTextItem::Text(text) => result.extend(render_text_to_lines(text, 0, terminal_width)),
222      LoggerTextItem::HangingText { text, indent } => {
223        result.extend(render_text_to_lines(text, *indent, terminal_width));
224      }
225    }
226  }
227  result
228}
229
230fn render_text_to_lines(text: &str, hanging_indent: u16, terminal_width: Option<u16>) -> Vec<String> {
231  let mut lines = Vec::new();
232  if let Some(terminal_width) = terminal_width {
233    let mut current_line = String::new();
234    let mut line_width: u16 = 0;
235    let mut current_whitespace = String::new();
236    for token in tokenize_words(text) {
237      match token {
238        WordToken::Word((word, word_width)) => {
239          let is_word_longer_than_line = hanging_indent + word_width > terminal_width;
240          if is_word_longer_than_line {
241            // break it up onto multiple lines with indentation
242            if !current_whitespace.is_empty() {
243              if line_width < terminal_width {
244                current_line.push_str(&current_whitespace);
245              }
246              current_whitespace = String::new();
247            }
248            for c in word.chars() {
249              if line_width == terminal_width {
250                lines.push(current_line);
251                current_line = String::new();
252                current_line.push_str(&" ".repeat(hanging_indent as usize));
253                line_width = hanging_indent;
254              }
255              current_line.push(c);
256              line_width += 1;
257            }
258          } else {
259            if line_width + word_width > terminal_width {
260              lines.push(current_line);
261              current_line = String::new();
262              current_line.push_str(&" ".repeat(hanging_indent as usize));
263              line_width = hanging_indent;
264              current_whitespace = String::new();
265            }
266            if !current_whitespace.is_empty() {
267              current_line.push_str(&current_whitespace);
268              current_whitespace = String::new();
269            }
270            current_line.push_str(word);
271            line_width += word_width;
272          }
273        }
274        WordToken::WhiteSpace(space_char) => {
275          current_whitespace.push(space_char);
276          line_width += 1;
277        }
278        WordToken::NewLine => {
279          lines.push(current_line);
280          current_line = String::new();
281          line_width = 0;
282        }
283      }
284    }
285    if !current_line.is_empty() {
286      lines.push(current_line);
287    }
288  } else {
289    for line in text.lines() {
290      lines.push(line.to_string());
291    }
292  }
293  lines
294}
295
296enum WordToken<'a> {
297  Word((&'a str, u16)),
298  WhiteSpace(char),
299  NewLine,
300}
301
302fn tokenize_words(text: &str) -> Vec<WordToken> {
303  // todo: how to write an iterator version?
304  let mut start_index = 0;
305  let mut tokens = Vec::new();
306  let mut word_width = 0;
307  for (index, c) in text.char_indices() {
308    if c.is_whitespace() || c == '\n' {
309      if word_width > 0 {
310        tokens.push(WordToken::Word((&text[start_index..index], word_width)));
311        word_width = 0;
312      }
313
314      if c == '\n' {
315        tokens.push(WordToken::NewLine);
316      } else {
317        tokens.push(WordToken::WhiteSpace(c));
318      }
319
320      start_index = index + c.len_utf8(); // start at next char
321    } else if !c.is_ascii_control() {
322      word_width += 1;
323    }
324  }
325  if word_width > 0 {
326    tokens.push(WordToken::Word((&text[start_index..text.len()], word_width)));
327  }
328  tokens
329}
330
331fn get_text_line_count(text: &str, terminal_width: u16) -> u16 {
332  let mut line_count: u16 = 0;
333  let mut line_width: u16 = 0;
334  for c in text.chars() {
335    if c.is_ascii_control() && !c.is_ascii_whitespace() {
336      // ignore
337    } else if c == '\n' {
338      line_count += 1;
339      line_width = 0;
340    } else if line_width == terminal_width {
341      line_width = 0;
342      line_count += 1;
343    } else {
344      line_width += 1;
345    }
346  }
347  line_count + 1
348}