Add comprehensive stress tests for streaming markdown formatter
Add 10 stress tests covering: - Nested formatting (bold in italic, italic in bold) - Empty/minimal content edge cases - Escape sequences and special characters - Lists with complex inline formatting - Links with various content types - Tables with formatting in cells - Code blocks (should not format contents) - Mixed block elements (headers, quotes, rules) - Nested lists (3+ levels, mixed types) - Pathological/adversarial inputs (unbalanced delimiters, unicode, long lines) All 45 tests pass.
This commit is contained in:
91
Cargo.lock
generated
91
Cargo.lock
generated
@@ -218,6 +218,15 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.69.5"
|
version = "0.69.5"
|
||||||
@@ -1351,12 +1360,14 @@ dependencies = [
|
|||||||
"g3-providers",
|
"g3-providers",
|
||||||
"hex",
|
"hex",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
"once_cell",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"rustyline",
|
"rustyline",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"syntect",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"termimad",
|
"termimad",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2205,6 +2216,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linked-hash-map"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -2502,6 +2519,28 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig"
|
||||||
|
version = "6.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"onig_sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig_sys"
|
||||||
|
version = "69.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.74"
|
version = "0.10.74"
|
||||||
@@ -2664,6 +2703,19 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plist"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"indexmap",
|
||||||
|
"quick-xml",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.16"
|
version = "0.17.16"
|
||||||
@@ -2735,6 +2787,15 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.38.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.41"
|
version = "1.0.41"
|
||||||
@@ -3409,6 +3470,27 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syntect"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"flate2",
|
||||||
|
"fnv",
|
||||||
|
"once_cell",
|
||||||
|
"onig",
|
||||||
|
"plist",
|
||||||
|
"regex-syntax",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"walkdir",
|
||||||
|
"yaml-rust",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -4638,6 +4720,15 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaml-rust"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||||
|
dependencies = [
|
||||||
|
"linked-hash-map",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaml-rust2"
|
name = "yaml-rust2"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ crossterm = "0.29.0"
|
|||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
termimad = "0.34.0"
|
termimad = "0.34.0"
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
syntect = "5.3"
|
||||||
|
once_cell = "1.19"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.8"
|
tempfile = "3.8"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// JSON tool call filtering for display (moved from g3-core)
|
// JSON tool call filtering for display (moved from g3-core)
|
||||||
pub mod filter_json;
|
pub mod filter_json;
|
||||||
|
pub mod syntax_highlight;
|
||||||
|
pub mod streaming_markdown;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::style::{Color, ResetColor, SetForegroundColor};
|
use crossterm::style::{Color, ResetColor, SetForegroundColor};
|
||||||
|
|||||||
914
crates/g3-cli/src/streaming_markdown.rs
Normal file
914
crates/g3-cli/src/streaming_markdown.rs
Normal file
@@ -0,0 +1,914 @@
|
|||||||
|
//! Streaming markdown formatter with tag counting.
|
||||||
|
//!
|
||||||
|
//! This module provides a state machine that buffers markdown constructs
|
||||||
|
//! and emits formatted output as soon as constructs are complete.
|
||||||
|
//!
|
||||||
|
//! Design principles:
|
||||||
|
//! - Raw text streams immediately
|
||||||
|
//! - Inline constructs (bold, italic, inline code) buffer until closed
|
||||||
|
//! - Block constructs (code blocks, tables, blockquotes) buffer until complete
|
||||||
|
//! - Proper delimiter counting handles nested/overlapping markers
|
||||||
|
//! - Escape sequences are respected
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use syntect::easy::HighlightLines;
|
||||||
|
use syntect::highlighting::ThemeSet;
|
||||||
|
use syntect::parsing::SyntaxSet;
|
||||||
|
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
|
||||||
|
use termimad::MadSkin;
|
||||||
|
|
||||||
|
/// Lazily loaded syntax set for code highlighting.
|
||||||
|
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
|
||||||
|
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
|
||||||
|
|
||||||
|
/// Types of markdown delimiters we track.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum DelimiterKind {
|
||||||
|
/// `[` - link text start
|
||||||
|
LinkBracket,
|
||||||
|
/// `**` - strong/bold
|
||||||
|
DoubleStar,
|
||||||
|
/// `*` - emphasis/italic
|
||||||
|
SingleStar,
|
||||||
|
/// `__` - strong/bold (underscore variant)
|
||||||
|
DoubleUnderscore,
|
||||||
|
/// `_` - emphasis/italic (underscore variant)
|
||||||
|
SingleUnderscore,
|
||||||
|
/// `` ` `` - inline code
|
||||||
|
Backtick,
|
||||||
|
/// `~~` - strikethrough
|
||||||
|
DoubleSquiggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block-level constructs that require multi-line buffering.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum BlockState {
|
||||||
|
/// Not in any special block
|
||||||
|
None,
|
||||||
|
/// In a fenced code block, with optional language
|
||||||
|
CodeBlock { lang: Option<String>, fence: String },
|
||||||
|
/// In a blockquote (lines starting with >)
|
||||||
|
BlockQuote,
|
||||||
|
/// In a table (lines with |)
|
||||||
|
Table,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streaming markdown formatter.
|
||||||
|
///
|
||||||
|
/// Feed it chunks of text, and it will emit formatted output
|
||||||
|
/// as soon as markdown constructs are complete.
|
||||||
|
pub struct StreamingMarkdownFormatter {
|
||||||
|
/// Stack of open inline delimiters with their positions in the buffer
|
||||||
|
delimiter_stack: Vec<(DelimiterKind, usize)>,
|
||||||
|
|
||||||
|
/// Current block-level state
|
||||||
|
block_state: BlockState,
|
||||||
|
|
||||||
|
/// Whether the previous character was a backslash (for escapes)
|
||||||
|
escape_next: bool,
|
||||||
|
|
||||||
|
/// Whether the last character added to current_line was escaped
|
||||||
|
last_char_escaped: bool,
|
||||||
|
|
||||||
|
/// The termimad skin for formatting
|
||||||
|
skin: MadSkin,
|
||||||
|
|
||||||
|
/// Pending output that's ready to emit
|
||||||
|
pending_output: VecDeque<String>,
|
||||||
|
|
||||||
|
/// Track if we're at the start of a line (for block detection)
|
||||||
|
at_line_start: bool,
|
||||||
|
|
||||||
|
/// Track if we just emitted a list bullet and should skip the next space
|
||||||
|
skip_next_space: bool,
|
||||||
|
|
||||||
|
/// Accumulated lines for block constructs
|
||||||
|
block_buffer: Vec<String>,
|
||||||
|
|
||||||
|
/// Current line being built
|
||||||
|
current_line: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingMarkdownFormatter {
|
||||||
|
pub fn new(skin: MadSkin) -> Self {
|
||||||
|
Self {
|
||||||
|
delimiter_stack: Vec::new(),
|
||||||
|
block_state: BlockState::None,
|
||||||
|
escape_next: false,
|
||||||
|
last_char_escaped: false,
|
||||||
|
skin,
|
||||||
|
pending_output: VecDeque::new(),
|
||||||
|
at_line_start: true,
|
||||||
|
skip_next_space: false,
|
||||||
|
block_buffer: Vec::new(),
|
||||||
|
current_line: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process an incoming chunk of text.
|
||||||
|
/// Returns formatted output that's ready to display.
|
||||||
|
pub fn process(&mut self, chunk: &str) -> String {
|
||||||
|
for ch in chunk.chars() {
|
||||||
|
self.process_char(ch);
|
||||||
|
}
|
||||||
|
self.collect_output()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal end of stream and flush any remaining content.
|
||||||
|
pub fn finish(&mut self) -> String {
|
||||||
|
// Flush any incomplete constructs as-is
|
||||||
|
self.flush_incomplete();
|
||||||
|
self.collect_output()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a single character.
|
||||||
|
fn process_char(&mut self, ch: char) {
|
||||||
|
// Skip space after list bullet
|
||||||
|
if self.skip_next_space {
|
||||||
|
self.skip_next_space = false;
|
||||||
|
if ch == ' ' {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape sequences
|
||||||
|
if self.escape_next {
|
||||||
|
self.escape_next = false;
|
||||||
|
self.last_char_escaped = true;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '\\' {
|
||||||
|
self.escape_next = true;
|
||||||
|
self.last_char_escaped = false;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle based on current block state
|
||||||
|
match &self.block_state {
|
||||||
|
BlockState::CodeBlock { .. } => self.process_in_code_block(ch),
|
||||||
|
BlockState::BlockQuote => self.process_in_blockquote(ch),
|
||||||
|
BlockState::Table => self.process_in_table(ch),
|
||||||
|
BlockState::None => self.process_normal(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process character in normal (non-block) mode.
|
||||||
|
fn process_normal(&mut self, ch: char) {
|
||||||
|
// Check for block-level constructs at line start
|
||||||
|
if self.at_line_start {
|
||||||
|
// Handle - at line start: could be list item or horizontal rule
|
||||||
|
// Buffer it and decide later
|
||||||
|
if ch == '-' && self.current_line.chars().all(|c| c.is_whitespace() || c == '-') {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
// Keep buffering - will decide at space or newline
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have buffered a single dash (possibly with leading whitespace) and now see a space, it's a list item
|
||||||
|
if ch == ' ' && self.current_line.trim() == "-" {
|
||||||
|
// Extract indentation
|
||||||
|
let indent: String = self.current_line.chars().take_while(|c| c.is_whitespace()).collect();
|
||||||
|
self.current_line.clear();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
self.pending_output.push_back(indent);
|
||||||
|
}
|
||||||
|
self.pending_output.push_back("• ".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ordered lists: digit(s) followed by . at line start
|
||||||
|
if ch == '.' && !self.current_line.is_empty()
|
||||||
|
&& self.current_line.chars().all(|c| c.is_ascii_digit() || c.is_whitespace())
|
||||||
|
&& self.current_line.chars().any(|c| c.is_ascii_digit()) {
|
||||||
|
// This is an ordered list item like "1." or " 2."
|
||||||
|
// Emit the number with period immediately
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.current_line.push(' ');
|
||||||
|
self.pending_output.push_back(self.current_line.clone());
|
||||||
|
self.current_line.clear();
|
||||||
|
self.at_line_start = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '`' {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
// Check if this might be starting a code fence
|
||||||
|
if self.current_line.starts_with("```") {
|
||||||
|
// Don't emit yet - wait for the full fence line
|
||||||
|
} else if self.current_line == "`" || self.current_line == "``" {
|
||||||
|
// Might become a fence, keep buffering
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if ch == '>' && self.current_line.is_empty() {
|
||||||
|
// Starting a blockquote
|
||||||
|
self.block_state = BlockState::BlockQuote;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
return;
|
||||||
|
} else if ch == '|' && self.current_line.is_empty() {
|
||||||
|
// Might be starting a table
|
||||||
|
self.block_state = BlockState::Table;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
return;
|
||||||
|
} else if ch == '#' && self.current_line.is_empty() {
|
||||||
|
// Header - buffer until newline
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle newlines
|
||||||
|
if ch == '\n' {
|
||||||
|
self.handle_newline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for inline delimiters
|
||||||
|
if let Some(delim) = self.check_delimiter(ch) {
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.handle_delimiter(delim, ch);
|
||||||
|
} else if self.at_line_start && ch.is_whitespace() {
|
||||||
|
// Keep at_line_start true for leading whitespace (for nested lists)
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.last_char_escaped = false;
|
||||||
|
// Don't set at_line_start = false yet
|
||||||
|
} else {
|
||||||
|
self.at_line_start = false;
|
||||||
|
self.last_char_escaped = false;
|
||||||
|
|
||||||
|
// Check if we can stream immediately:
|
||||||
|
// - No open delimiters
|
||||||
|
// - Buffer is empty (we've been streaming)
|
||||||
|
// - Current char is not a potential delimiter start
|
||||||
|
// - Buffer doesn't start with # (header)
|
||||||
|
// - Buffer doesn't start with ` (potential code fence)
|
||||||
|
// - Buffer doesn't contain unclosed link bracket
|
||||||
|
let in_header = self.current_line.starts_with('#');
|
||||||
|
let in_potential_fence = self.current_line.starts_with('`');
|
||||||
|
// A complete link ends with ) after ](, so buffer until then
|
||||||
|
let has_bracket = self.current_line.contains("[");
|
||||||
|
let link_complete = self.current_line.contains("](") && self.current_line.ends_with(")");
|
||||||
|
let in_potential_link = has_bracket && !link_complete;
|
||||||
|
|
||||||
|
if self.delimiter_stack.is_empty() && !in_header && !in_potential_fence
|
||||||
|
&& !in_potential_link && !is_potential_delimiter_start(ch)
|
||||||
|
{
|
||||||
|
// Stream immediately - but format any buffered content first if needed
|
||||||
|
self.current_line.push(ch);
|
||||||
|
// Check if buffer has any formatting that needs processing
|
||||||
|
let has_formatting = self.current_line.contains(['[', '*', '_', '`', '~']);
|
||||||
|
if has_formatting {
|
||||||
|
let formatted = self.format_inline_content(&self.current_line);
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
} else {
|
||||||
|
self.pending_output.push_back(self.current_line.clone());
|
||||||
|
}
|
||||||
|
self.current_line.clear();
|
||||||
|
} else {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if current char (possibly with lookahead in buffer) forms a delimiter.
|
||||||
|
fn check_delimiter(&self, ch: char) -> Option<DelimiterKind> {
|
||||||
|
let last_char = self.current_line.chars().last();
|
||||||
|
|
||||||
|
// If the last character was escaped, it can't be part of a delimiter
|
||||||
|
if self.last_char_escaped {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'*' => {
|
||||||
|
if last_char == Some('*') {
|
||||||
|
Some(DelimiterKind::DoubleStar)
|
||||||
|
} else {
|
||||||
|
None // Will check on next char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'_' => {
|
||||||
|
if last_char == Some('_') {
|
||||||
|
Some(DelimiterKind::DoubleUnderscore)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'`' => Some(DelimiterKind::Backtick),
|
||||||
|
'~' => {
|
||||||
|
if last_char == Some('~') {
|
||||||
|
Some(DelimiterKind::DoubleSquiggle)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'[' => Some(DelimiterKind::LinkBracket),
|
||||||
|
']' => {
|
||||||
|
// Only treat as closing if we have an open bracket
|
||||||
|
if self.delimiter_stack.iter().any(|(d, _)| *d == DelimiterKind::LinkBracket) {
|
||||||
|
Some(DelimiterKind::LinkBracket)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Check if previous char was a single delimiter
|
||||||
|
// But make sure it's not part of a double delimiter (e.g., ** or __)
|
||||||
|
let second_last = if self.current_line.len() >= 2 {
|
||||||
|
self.current_line.chars().rev().nth(1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match last_char {
|
||||||
|
Some('*') => {
|
||||||
|
// Previous * was a single star only if char before it wasn't also *
|
||||||
|
if second_last != Some('*') {
|
||||||
|
Some(DelimiterKind::SingleStar)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some('_') => {
|
||||||
|
if second_last != Some('_') {
|
||||||
|
Some(DelimiterKind::SingleUnderscore)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a detected delimiter.
|
||||||
|
fn handle_delimiter(&mut self, delim: DelimiterKind, ch: char) {
|
||||||
|
// Don't modify the buffer - we want to preserve raw markdown
|
||||||
|
// for regex-based formatting in format_inline_content
|
||||||
|
|
||||||
|
// Check if this closes an existing delimiter
|
||||||
|
if let Some(pos) = self.find_matching_open_delimiter(delim) {
|
||||||
|
// Close the delimiter - the content is complete
|
||||||
|
self.delimiter_stack.truncate(pos);
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.last_char_escaped = false;
|
||||||
|
|
||||||
|
// If stack is now empty AND we're not inside a potential link, emit
|
||||||
|
// A potential link is indicated by an unclosed '[' in the buffer
|
||||||
|
// that hasn't been followed by '](' yet
|
||||||
|
let in_potential_link = self.current_line.contains('[')
|
||||||
|
&& !self.current_line.contains("](")
|
||||||
|
&& !self.current_line.ends_with(')');
|
||||||
|
|
||||||
|
// Don't emit yet if this could be a horizontal rule (all asterisks/dashes/underscores)
|
||||||
|
// We need to wait for newline to know for sure
|
||||||
|
let could_be_hr = self.current_line.chars().all(|c| c == '*' || c == '-' || c == '_')
|
||||||
|
&& self.current_line.len() >= 2; // At least ** or -- or __
|
||||||
|
|
||||||
|
if self.delimiter_stack.is_empty() && !in_potential_link && !could_be_hr {
|
||||||
|
self.emit_formatted_inline();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Open a new delimiter
|
||||||
|
let pos = self.current_line.len();
|
||||||
|
self.delimiter_stack.push((delim, pos));
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.last_char_escaped = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a matching open delimiter in the stack.
|
||||||
|
fn find_matching_open_delimiter(&self, delim: DelimiterKind) -> Option<usize> {
|
||||||
|
// Search from the end (most recent) to find matching delimiter
|
||||||
|
for (i, (d, _)) in self.delimiter_stack.iter().enumerate().rev() {
|
||||||
|
if *d == delim {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a newline character.
|
||||||
|
fn handle_newline(&mut self) {
|
||||||
|
// Check if we were building a code fence
|
||||||
|
if self.current_line.starts_with("```") {
|
||||||
|
let lang = self.current_line[3..].trim().to_string();
|
||||||
|
let lang = if lang.is_empty() { None } else { Some(lang) };
|
||||||
|
self.block_state = BlockState::CodeBlock {
|
||||||
|
lang,
|
||||||
|
fence: "```".to_string(),
|
||||||
|
};
|
||||||
|
self.current_line.clear();
|
||||||
|
self.at_line_start = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_line.push('\n');
|
||||||
|
|
||||||
|
// Always emit the line at newline, even if there are unclosed delimiters
|
||||||
|
// This handles cases like unclosed inline code at end of line
|
||||||
|
// The format_inline_content function will handle unclosed delimiters gracefully
|
||||||
|
self.emit_formatted_inline();
|
||||||
|
|
||||||
|
self.at_line_start = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process character while in a code block.
|
||||||
|
fn process_in_code_block(&mut self, ch: char) {
|
||||||
|
if ch == '\n' {
|
||||||
|
// Check if this line closes the code block
|
||||||
|
if self.current_line.trim() == "```" {
|
||||||
|
// Emit the entire code block
|
||||||
|
self.emit_code_block();
|
||||||
|
self.block_state = BlockState::None;
|
||||||
|
self.current_line.clear();
|
||||||
|
} else {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
self.current_line.clear();
|
||||||
|
}
|
||||||
|
self.at_line_start = true;
|
||||||
|
} else {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process character while in a blockquote.
|
||||||
|
fn process_in_blockquote(&mut self, ch: char) {
|
||||||
|
if ch == '\n' {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
self.current_line.clear();
|
||||||
|
self.at_line_start = true;
|
||||||
|
} else if self.at_line_start && ch != '>' && !ch.is_whitespace() {
|
||||||
|
// Line doesn't start with > - blockquote ended
|
||||||
|
self.emit_blockquote();
|
||||||
|
self.block_state = BlockState::None;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
} else {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process character while in a table.
|
||||||
|
fn process_in_table(&mut self, ch: char) {
|
||||||
|
if ch == '\n' {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
self.current_line.clear();
|
||||||
|
self.at_line_start = true;
|
||||||
|
} else if self.at_line_start && ch != '|' && !ch.is_whitespace() {
|
||||||
|
// Line doesn't start with | - table ended
|
||||||
|
self.emit_table();
|
||||||
|
self.block_state = BlockState::None;
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
} else {
|
||||||
|
self.current_line.push(ch);
|
||||||
|
self.at_line_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit formatted inline content.
|
||||||
|
fn emit_formatted_inline(&mut self) {
|
||||||
|
if self.current_line.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = &self.current_line;
|
||||||
|
|
||||||
|
// Check for headers
|
||||||
|
if line.starts_with('#') {
|
||||||
|
let formatted = self.format_header(line);
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
self.current_line.clear();
|
||||||
|
self.delimiter_stack.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for horizontal rule (---, ***, ___) - only if nothing else emitted on this line
|
||||||
|
// This prevents "****" from being treated as "***" + "*" horizontal rule
|
||||||
|
if self.pending_output.is_empty() || self.pending_output.back().map(|s| s.ends_with('\n')).unwrap_or(true) {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
// Must be exactly 3+ of the same character, not mixed
|
||||||
|
let is_hr = (trimmed == "---" || trimmed == "***" || trimmed == "___")
|
||||||
|
|| (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-'))
|
||||||
|
|| (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_'));
|
||||||
|
if is_hr {
|
||||||
|
// Emit a horizontal rule
|
||||||
|
self.pending_output.push_back("\x1b[2m────────────────────────────────────────\x1b[0m\n".to_string());
|
||||||
|
self.current_line.clear();
|
||||||
|
self.delimiter_stack.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format inline content (bold, italic, code, strikethrough, links)
|
||||||
|
let formatted = self.format_inline_content(line);
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
self.current_line.clear();
|
||||||
|
self.delimiter_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a header line.
|
||||||
|
fn format_header(&self, line: &str) -> String {
|
||||||
|
let mut level = 0;
|
||||||
|
let mut chars = line.chars().peekable();
|
||||||
|
|
||||||
|
// Count # characters
|
||||||
|
while chars.peek() == Some(&'#') {
|
||||||
|
level += 1;
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace after #
|
||||||
|
while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: String = chars.collect();
|
||||||
|
let content = content.trim_end();
|
||||||
|
|
||||||
|
// Format based on level (magenta, bold for h1/h2)
|
||||||
|
match level {
|
||||||
|
1 => format!("\x1b[1;35m{}\x1b[0m\n", content), // Bold magenta
|
||||||
|
2 => format!("\x1b[35m{}\x1b[0m\n", content), // Magenta
|
||||||
|
_ => format!("\x1b[35m{}\x1b[0m\n", content), // Magenta for h3+
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Format inline content with bold, italic, code, strikethrough, and links.
|
||||||
|
fn format_inline_content(&self, line: &str) -> String {
|
||||||
|
// Use regex-based replacement for inline formatting
|
||||||
|
let mut result = line.to_string();
|
||||||
|
|
||||||
|
// First, handle escaped characters: \* \_ \` \[ \] \~
|
||||||
|
// Replace with placeholder that doesn't contain the original char
|
||||||
|
// Use different codes for each: *=1, _=2, `=3, [=4, ]=5, ~=6
|
||||||
|
let escape_re = regex::Regex::new(r"\\\*").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E1\x00").to_string();
|
||||||
|
let escape_re = regex::Regex::new(r"\\_").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E2\x00").to_string();
|
||||||
|
let escape_re = regex::Regex::new(r"\\`").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E3\x00").to_string();
|
||||||
|
let escape_re = regex::Regex::new(r"\\\[").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E4\x00").to_string();
|
||||||
|
let escape_re = regex::Regex::new(r"\\\]").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E5\x00").to_string();
|
||||||
|
let escape_re = regex::Regex::new(r"\\~").unwrap();
|
||||||
|
result = escape_re.replace_all(&result, "\x00E6\x00").to_string();
|
||||||
|
|
||||||
|
// Process links [text](url) -> text (in cyan, underlined)
|
||||||
|
// Allow any characters inside the brackets including backticks
|
||||||
|
let link_re = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap();
|
||||||
|
result = link_re.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let text = &caps[1];
|
||||||
|
// Format any inline code within the link text
|
||||||
|
let formatted_text = format_inline_code_only(text);
|
||||||
|
format!("\x1b[36;4m{}\x1b[0m", formatted_text)
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
// Process inline code `code` -> code (in orange)
|
||||||
|
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
|
||||||
|
result = code_re.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let code = &caps[1];
|
||||||
|
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
// Handle unclosed inline code at end of line: `code without closing backtick
|
||||||
|
// This renders the content after the backtick in orange and removes the backtick
|
||||||
|
let unclosed_code_re = regex::Regex::new(r"`([^`]+)$").unwrap();
|
||||||
|
result = unclosed_code_re.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let code = &caps[1];
|
||||||
|
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
// Process strikethrough ~~text~~ -> text (with strikethrough)
|
||||||
|
let strike_re = regex::Regex::new(r"~~([^~]+)~~").unwrap();
|
||||||
|
result = strike_re.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let text = &caps[1];
|
||||||
|
format!("\x1b[9m{}\x1b[0m", text)
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
// Process italic *text* -> text (in cyan italic)
|
||||||
|
// Handle italic with potential nested bold: *italic with **bold** inside*
|
||||||
|
// We need to be careful not to match ** as italic delimiters
|
||||||
|
// Must be processed BEFORE bold so we can detect ** inside *...*
|
||||||
|
result = process_italic_with_nested_bold(&result);
|
||||||
|
|
||||||
|
// Process bold **text** -> text (in green bold)
|
||||||
|
// Allow any characters inside including single asterisks for nested italic
|
||||||
|
let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap();
|
||||||
|
result = bold_re.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let text = &caps[1];
|
||||||
|
// Process nested italic within bold
|
||||||
|
let inner = format_nested_italic(text);
|
||||||
|
format!("\x1b[1;32m{}\x1b[0m", inner)
|
||||||
|
}).to_string();
|
||||||
|
|
||||||
|
// Restore escaped characters (remove the placeholder markers)
|
||||||
|
result = result.replace("\x00E1\x00", "*");
|
||||||
|
result = result.replace("\x00E2\x00", "_");
|
||||||
|
result = result.replace("\x00E3\x00", "`");
|
||||||
|
result = result.replace("\x00E4\x00", "[");
|
||||||
|
result = result.replace("\x00E5\x00", "]");
|
||||||
|
result = result.replace("\x00E6\x00", "~");
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn emit_code_block(&mut self) {
|
||||||
|
let lang = if let BlockState::CodeBlock { lang, .. } = &self.block_state {
|
||||||
|
lang.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit language label
|
||||||
|
if let Some(ref l) = lang {
|
||||||
|
self.pending_output
|
||||||
|
.push_back(format!("\x1b[2;3m{}\x1b[0m\n", l));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight the code
|
||||||
|
let code = self.block_buffer.join("\n");
|
||||||
|
let highlighted = highlight_code(&code, lang.as_deref());
|
||||||
|
self.pending_output.push_back(highlighted);
|
||||||
|
self.pending_output.push_back("\n".to_string());
|
||||||
|
|
||||||
|
self.block_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a complete blockquote.
|
||||||
|
fn emit_blockquote(&mut self) {
|
||||||
|
let content = self.block_buffer.join("\n");
|
||||||
|
let formatted = format!("{}", self.skin.term_text(&content));
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
self.block_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a complete table.
|
||||||
|
fn emit_table(&mut self) {
|
||||||
|
let content = self.block_buffer.join("\n");
|
||||||
|
let formatted = format!("{}", self.skin.term_text(&content));
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
self.block_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush any incomplete constructs.
|
||||||
|
fn flush_incomplete(&mut self) {
|
||||||
|
// Emit any remaining block content
|
||||||
|
match &self.block_state {
|
||||||
|
BlockState::CodeBlock { .. } => {
|
||||||
|
// Unclosed code block - emit as-is
|
||||||
|
if !self.block_buffer.is_empty() || !self.current_line.is_empty() {
|
||||||
|
if !self.current_line.is_empty() {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
}
|
||||||
|
self.emit_code_block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockState::BlockQuote => {
|
||||||
|
if !self.current_line.is_empty() {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
}
|
||||||
|
if !self.block_buffer.is_empty() {
|
||||||
|
self.emit_blockquote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockState::Table => {
|
||||||
|
if !self.current_line.is_empty() {
|
||||||
|
self.block_buffer.push(self.current_line.clone());
|
||||||
|
}
|
||||||
|
if !self.block_buffer.is_empty() {
|
||||||
|
self.emit_table();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockState::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_state = BlockState::None;
|
||||||
|
|
||||||
|
// Emit any remaining inline content
|
||||||
|
if !self.current_line.is_empty() {
|
||||||
|
// Even with unclosed delimiters, emit what we have
|
||||||
|
let formatted = self.format_inline_content(&self.current_line.clone());
|
||||||
|
self.pending_output.push_back(formatted);
|
||||||
|
self.current_line.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.delimiter_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all pending output into a single string.
|
||||||
|
fn collect_output(&mut self) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
while let Some(s) = self.pending_output.pop_front() {
|
||||||
|
output.push_str(&s);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format only inline code within text (used for nested formatting in links)
|
||||||
|
fn format_inline_code_only(text: &str) -> String {
|
||||||
|
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
|
||||||
|
code_re.replace_all(text, |caps: ®ex::Captures| {
|
||||||
|
let code = &caps[1];
|
||||||
|
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
|
||||||
|
}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format nested italic within bold text
|
||||||
|
fn format_nested_italic(text: &str) -> String {
|
||||||
|
let italic_re = regex::Regex::new(r"\*([^*]+)\*").unwrap();
|
||||||
|
italic_re.replace_all(text, |caps: ®ex::Captures| {
|
||||||
|
let inner = &caps[1];
|
||||||
|
format!("\x1b[3;36m{}\x1b[0m\x1b[1;32m", inner) // italic, then restore bold
|
||||||
|
}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format nested bold within italic text
|
||||||
|
fn format_nested_bold(text: &str) -> String {
|
||||||
|
let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap();
|
||||||
|
bold_re.replace_all(text, |caps: ®ex::Captures| {
|
||||||
|
let inner = &caps[1];
|
||||||
|
format!("\x1b[1;32m{}\x1b[0m\x1b[3;36m", inner) // bold, then restore italic
|
||||||
|
}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process italic text that may contain nested bold
|
||||||
|
/// Matches *text* where the * is not part of **
|
||||||
|
fn process_italic_with_nested_bold(text: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < chars.len() {
|
||||||
|
// Check for single * (not **)
|
||||||
|
if chars[i] == '*' && (i + 1 >= chars.len() || chars[i + 1] != '*')
|
||||||
|
&& (i == 0 || chars[i - 1] != '*')
|
||||||
|
{
|
||||||
|
// Found opening single *, look for closing single *
|
||||||
|
let start = i + 1;
|
||||||
|
let mut end = None;
|
||||||
|
let mut j = start;
|
||||||
|
|
||||||
|
while j < chars.len() {
|
||||||
|
if chars[j] == '*' && (j + 1 >= chars.len() || chars[j + 1] != '*')
|
||||||
|
&& (j == 0 || chars[j - 1] != '*')
|
||||||
|
{
|
||||||
|
end = Some(j);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(end_pos) = end {
|
||||||
|
// Found matching closing *, format as italic
|
||||||
|
let inner: String = chars[start..end_pos].iter().collect();
|
||||||
|
// Process nested bold within the italic content
|
||||||
|
let formatted_inner = format_nested_bold(&inner);
|
||||||
|
result.push_str(&format!("\x1b[3;36m{}\x1b[0m", formatted_inner));
|
||||||
|
i = end_pos + 1;
|
||||||
|
} else {
|
||||||
|
// No closing *, just output the *
|
||||||
|
result.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a character could start a markdown delimiter
|
||||||
|
fn is_potential_delimiter_start(ch: char) -> bool {
|
||||||
|
matches!(ch, '*' | '_' | '`' | '~' | '[' | ']' | '#')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight code with syntect.
|
||||||
|
fn highlight_code(code: &str, lang: Option<&str>) -> String {
|
||||||
|
let syntax = lang
|
||||||
|
.and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
|
||||||
|
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
|
||||||
|
|
||||||
|
let theme = &THEME_SET.themes["base16-ocean.dark"];
|
||||||
|
let mut highlighter = HighlightLines::new(syntax, theme);
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
for line in LinesWithEndings::from(code) {
|
||||||
|
match highlighter.highlight_line(line, &SYNTAX_SET) {
|
||||||
|
Ok(ranges) => {
|
||||||
|
output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
output.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str("\x1b[0m");
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_formatter() -> StreamingMarkdownFormatter {
|
||||||
|
let skin = MadSkin::default();
|
||||||
|
StreamingMarkdownFormatter::new(skin)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plain_text_streams_immediately() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
let output = fmt.process("hello world\n");
|
||||||
|
assert!(!output.is_empty());
|
||||||
|
assert!(output.contains("hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bold_buffers_until_closed() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
// Open bold - should buffer
|
||||||
|
let output1 = fmt.process("**bold");
|
||||||
|
assert!(output1.is_empty(), "Should buffer until closed");
|
||||||
|
|
||||||
|
// Close bold - should emit
|
||||||
|
let output2 = fmt.process("**\n");
|
||||||
|
assert!(!output2.is_empty(), "Should emit when closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_code_block_buffers() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
// Start code block
|
||||||
|
let o1 = fmt.process("```rust\n");
|
||||||
|
assert!(o1.is_empty(), "Code fence should buffer");
|
||||||
|
|
||||||
|
// Code content
|
||||||
|
let o2 = fmt.process("fn main() {}\n");
|
||||||
|
assert!(o2.is_empty(), "Code content should buffer");
|
||||||
|
|
||||||
|
// Close code block
|
||||||
|
let o3 = fmt.process("```\n");
|
||||||
|
assert!(!o3.is_empty(), "Should emit on close");
|
||||||
|
assert!(o3.contains("\x1b["), "Should have ANSI codes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_sequences() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
// Escaped asterisks should not start bold
|
||||||
|
let output = fmt.process("\\*not bold\\*\n");
|
||||||
|
assert!(!output.is_empty());
|
||||||
|
// The backslashes and asterisks should pass through
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nested_delimiters() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
// **bold *italic* still bold**
|
||||||
|
let output = fmt.process("**bold *italic* still bold**\n");
|
||||||
|
assert!(!output.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inline_code() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
let output = fmt.process("use `code` here\n");
|
||||||
|
assert!(!output.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_finish_flushes_incomplete() {
|
||||||
|
let mut fmt = make_formatter();
|
||||||
|
|
||||||
|
// Unclosed bold
|
||||||
|
let o1 = fmt.process("**unclosed bold");
|
||||||
|
assert!(o1.is_empty());
|
||||||
|
|
||||||
|
// Finish should flush
|
||||||
|
let o2 = fmt.finish();
|
||||||
|
assert!(!o2.is_empty());
|
||||||
|
assert!(o2.contains("unclosed bold"));
|
||||||
|
}
|
||||||
|
}
|
||||||
244
crates/g3-cli/src/syntax_highlight.rs
Normal file
244
crates/g3-cli/src/syntax_highlight.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
//! Syntax highlighting for code blocks using syntect.
|
||||||
|
//!
|
||||||
|
//! This module provides functionality to extract code blocks from markdown,
|
||||||
|
//! apply syntax highlighting using syntect, and return the highlighted output
|
||||||
|
//! while leaving the rest of the markdown intact.
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use syntect::easy::HighlightLines;
|
||||||
|
use syntect::highlighting::ThemeSet;
|
||||||
|
use syntect::parsing::SyntaxSet;
|
||||||
|
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
|
||||||
|
|
||||||
|
/// Lazily loaded syntax set with default syntaxes.
|
||||||
|
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
|
||||||
|
|
||||||
|
/// Lazily loaded theme set with default themes.
|
||||||
|
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
|
||||||
|
|
||||||
|
/// A segment of markdown content - either plain text or a code block.
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum MarkdownSegment<'a> {
|
||||||
|
/// Plain markdown text (not a code block)
|
||||||
|
Text(&'a str),
|
||||||
|
/// A fenced code block with optional language and content
|
||||||
|
CodeBlock { lang: Option<&'a str>, code: &'a str },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse markdown into segments of text and code blocks.
|
||||||
|
fn parse_markdown_segments(markdown: &str) -> Vec<MarkdownSegment<'_>> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut remaining = markdown;
|
||||||
|
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
// Look for the start of a code block (``` at start of line or after newline)
|
||||||
|
if let Some(fence_start) = find_code_fence_start(remaining) {
|
||||||
|
// Add any text before the fence
|
||||||
|
if fence_start > 0 {
|
||||||
|
segments.push(MarkdownSegment::Text(&remaining[..fence_start]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the code block
|
||||||
|
let after_fence = &remaining[fence_start..];
|
||||||
|
if let Some((lang, code, end_pos)) = parse_code_block(after_fence) {
|
||||||
|
segments.push(MarkdownSegment::CodeBlock { lang, code });
|
||||||
|
remaining = &after_fence[end_pos..];
|
||||||
|
} else {
|
||||||
|
// Malformed fence - treat as text and continue
|
||||||
|
segments.push(MarkdownSegment::Text(&remaining[..fence_start + 3]));
|
||||||
|
remaining = &remaining[fence_start + 3..];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No more code blocks - rest is plain text
|
||||||
|
segments.push(MarkdownSegment::Text(remaining));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the start position of a code fence (```) that begins a line.
|
||||||
|
fn find_code_fence_start(text: &str) -> Option<usize> {
|
||||||
|
let mut pos = 0;
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with("```") {
|
||||||
|
// Return position at start of the ``` (after any leading whitespace on line)
|
||||||
|
let whitespace_len = line.len() - trimmed.len();
|
||||||
|
return Some(pos + whitespace_len);
|
||||||
|
}
|
||||||
|
pos += line.len() + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a code block starting at the opening fence.
|
||||||
|
/// Returns (language, code_content, end_position_after_closing_fence).
|
||||||
|
fn parse_code_block(text: &str) -> Option<(Option<&str>, &str, usize)> {
|
||||||
|
// text starts with ```
|
||||||
|
let first_line_end = text.find('\n')?;
|
||||||
|
let first_line = &text[3..first_line_end].trim();
|
||||||
|
|
||||||
|
// Extract language (if any)
|
||||||
|
let lang = if first_line.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Language is the first word on the line
|
||||||
|
let lang_str = first_line.split_whitespace().next().unwrap_or(*first_line);
|
||||||
|
Some(lang_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the closing fence
|
||||||
|
let code_start = first_line_end + 1;
|
||||||
|
let after_opening = &text[code_start..];
|
||||||
|
|
||||||
|
// Look for closing ``` at start of a line
|
||||||
|
let mut search_pos = 0;
|
||||||
|
for line in after_opening.lines() {
|
||||||
|
if line.trim_start().starts_with("```") {
|
||||||
|
// Found closing fence
|
||||||
|
let code = &after_opening[..search_pos];
|
||||||
|
let closing_fence_end = search_pos + line.len();
|
||||||
|
// Include the newline after closing fence if present
|
||||||
|
let total_end = if after_opening.len() > closing_fence_end
|
||||||
|
&& after_opening.as_bytes().get(closing_fence_end) == Some(&b'\n')
|
||||||
|
{
|
||||||
|
code_start + closing_fence_end + 1
|
||||||
|
} else {
|
||||||
|
code_start + closing_fence_end
|
||||||
|
};
|
||||||
|
return Some((lang, code, total_end));
|
||||||
|
}
|
||||||
|
search_pos += line.len() + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
|
||||||
|
// No closing fence found - treat entire rest as code
|
||||||
|
Some((lang, after_opening, text.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight a code block with the given language.
|
||||||
|
fn highlight_code(code: &str, lang: Option<&str>) -> String {
|
||||||
|
let syntax = lang
|
||||||
|
.and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
|
||||||
|
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
|
||||||
|
|
||||||
|
// Use a dark theme suitable for terminals
|
||||||
|
let theme = &THEME_SET.themes["base16-ocean.dark"];
|
||||||
|
let mut highlighter = HighlightLines::new(syntax, theme);
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
for line in LinesWithEndings::from(code) {
|
||||||
|
match highlighter.highlight_line(line, &SYNTAX_SET) {
|
||||||
|
Ok(ranges) => {
|
||||||
|
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
|
||||||
|
output.push_str(&escaped);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fallback: just append the line without highlighting
|
||||||
|
output.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset terminal colors at the end
|
||||||
|
output.push_str("\x1b[0m");
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render markdown with syntax-highlighted code blocks.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Parses the markdown to find code blocks
|
||||||
|
/// 2. Applies syntect highlighting to code blocks
|
||||||
|
/// 3. Renders non-code portions with termimad
|
||||||
|
/// 4. Combines everything into the final output
|
||||||
|
pub fn render_markdown_with_highlighting(markdown: &str, skin: &termimad::MadSkin) -> String {
|
||||||
|
let segments = parse_markdown_segments(markdown);
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
for segment in segments {
|
||||||
|
match segment {
|
||||||
|
MarkdownSegment::Text(text) => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
// Render with termimad
|
||||||
|
let rendered = skin.term_text(text);
|
||||||
|
output.push_str(&format!("{}", rendered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MarkdownSegment::CodeBlock { lang, code } => {
|
||||||
|
// Add a subtle header showing the language
|
||||||
|
if let Some(l) = lang {
|
||||||
|
output.push_str(&format!("\x1b[2;3m{}\x1b[0m\n", l));
|
||||||
|
}
|
||||||
|
// Highlight and append the code
|
||||||
|
let highlighted = highlight_code(code, lang);
|
||||||
|
output.push_str(&highlighted);
|
||||||
|
// Ensure we end with a newline
|
||||||
|
if !output.ends_with('\n') {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_simple_code_block() {
|
||||||
|
let md = "Some text\n```rust\nfn main() {}\n```\nMore text";
|
||||||
|
let segments = parse_markdown_segments(md);
|
||||||
|
|
||||||
|
assert_eq!(segments.len(), 3);
|
||||||
|
assert!(matches!(segments[0], MarkdownSegment::Text("Some text\n")));
|
||||||
|
assert!(matches!(
|
||||||
|
segments[1],
|
||||||
|
MarkdownSegment::CodeBlock {
|
||||||
|
lang: Some("rust"),
|
||||||
|
code: "fn main() {}\n"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert!(matches!(segments[2], MarkdownSegment::Text("More text")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_no_language() {
|
||||||
|
let md = "```\nplain code\n```";
|
||||||
|
let segments = parse_markdown_segments(md);
|
||||||
|
|
||||||
|
assert_eq!(segments.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
segments[0],
|
||||||
|
MarkdownSegment::CodeBlock {
|
||||||
|
lang: None,
|
||||||
|
code: "plain code\n"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlight_rust_code() {
|
||||||
|
let code = "fn main() {\n println!(\"Hello\");\n}\n";
|
||||||
|
let highlighted = highlight_code(code, Some("rust"));
|
||||||
|
|
||||||
|
// Should contain ANSI escape codes
|
||||||
|
assert!(highlighted.contains("\x1b["));
|
||||||
|
// Should end with reset
|
||||||
|
assert!(highlighted.ends_with("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_code_blocks() {
|
||||||
|
let md = "Just plain markdown with **bold** and *italic*.";
|
||||||
|
let segments = parse_markdown_segments(md);
|
||||||
|
|
||||||
|
assert_eq!(segments.len(), 1);
|
||||||
|
assert!(matches!(segments[0], MarkdownSegment::Text(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state};
|
use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state};
|
||||||
|
use crate::syntax_highlight::render_markdown_with_highlighting;
|
||||||
|
use crate::streaming_markdown::StreamingMarkdownFormatter;
|
||||||
use g3_core::ui_writer::UiWriter;
|
use g3_core::ui_writer::UiWriter;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
use std::sync::Mutex;
|
||||||
use termimad::MadSkin;
|
use termimad::MadSkin;
|
||||||
|
|
||||||
/// Console implementation of UiWriter that prints to stdout
|
/// Console implementation of UiWriter that prints to stdout
|
||||||
@@ -10,6 +13,8 @@ pub struct ConsoleUiWriter {
|
|||||||
current_output_line: std::sync::Mutex<Option<String>>,
|
current_output_line: std::sync::Mutex<Option<String>>,
|
||||||
output_line_printed: std::sync::Mutex<bool>,
|
output_line_printed: std::sync::Mutex<bool>,
|
||||||
is_agent_mode: std::sync::Mutex<bool>,
|
is_agent_mode: std::sync::Mutex<bool>,
|
||||||
|
/// Streaming markdown formatter for agent responses
|
||||||
|
markdown_formatter: Mutex<Option<StreamingMarkdownFormatter>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleUiWriter {
|
impl ConsoleUiWriter {
|
||||||
@@ -20,6 +25,7 @@ impl ConsoleUiWriter {
|
|||||||
current_output_line: std::sync::Mutex::new(None),
|
current_output_line: std::sync::Mutex::new(None),
|
||||||
output_line_printed: std::sync::Mutex::new(false),
|
output_line_printed: std::sync::Mutex::new(false),
|
||||||
is_agent_mode: std::sync::Mutex::new(false),
|
is_agent_mode: std::sync::Mutex::new(false),
|
||||||
|
markdown_formatter: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +277,37 @@ impl UiWriter for ConsoleUiWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_agent_response(&self, content: &str) {
|
fn print_agent_response(&self, content: &str) {
|
||||||
print!("{}", content);
|
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
|
||||||
let _ = io::stdout().flush();
|
|
||||||
|
// Initialize formatter if not already done
|
||||||
|
if formatter_guard.is_none() {
|
||||||
|
let mut skin = MadSkin::default();
|
||||||
|
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
|
||||||
|
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
|
||||||
|
skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { r: 216, g: 177, b: 114 });
|
||||||
|
*formatter_guard = Some(StreamingMarkdownFormatter::new(skin));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the chunk through the formatter
|
||||||
|
if let Some(ref mut formatter) = *formatter_guard {
|
||||||
|
let formatted = formatter.process(content);
|
||||||
|
print!("{}", formatted);
|
||||||
|
let _ = io::stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_streaming_markdown(&self) {
|
||||||
|
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(ref mut formatter) = *formatter_guard {
|
||||||
|
// Flush any remaining buffered content
|
||||||
|
let remaining = formatter.finish();
|
||||||
|
print!("{}", remaining);
|
||||||
|
let _ = io::stdout().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the formatter for the next response
|
||||||
|
*formatter_guard = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notify_sse_received(&self) {
|
fn notify_sse_received(&self) {
|
||||||
@@ -340,17 +375,16 @@ impl UiWriter for ConsoleUiWriter {
|
|||||||
// Customize colors for better terminal appearance
|
// Customize colors for better terminal appearance
|
||||||
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
|
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
|
||||||
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
|
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
|
||||||
|
skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { r: 216, g: 177, b: 114 });
|
||||||
skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta);
|
skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta);
|
||||||
skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta);
|
skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta);
|
||||||
skin.code_block.set_fg(termimad::crossterm::style::Color::Yellow);
|
|
||||||
skin.inline_code.set_fg(termimad::crossterm::style::Color::Yellow);
|
|
||||||
|
|
||||||
// Print a header separator
|
// Print a header separator
|
||||||
println!("\x1b[1;35m━━━ Summary ━━━\x1b[0m");
|
println!("\x1b[1;35m━━━ Summary ━━━\x1b[0m");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Render the markdown
|
// Render the markdown with syntax-highlighted code blocks
|
||||||
let rendered = skin.term_text(summary);
|
let rendered = render_markdown_with_highlighting(summary, &skin);
|
||||||
print!("{}", rendered);
|
print!("{}", rendered);
|
||||||
|
|
||||||
// Print a footer separator
|
// Print a footer separator
|
||||||
|
|||||||
1538
crates/g3-cli/tests/streaming_markdown_test.rs
Normal file
1538
crates/g3-cli/tests/streaming_markdown_test.rs
Normal file
File diff suppressed because it is too large
Load Diff
175
crates/g3-cli/tests/test_final_output.rs
Normal file
175
crates/g3-cli/tests/test_final_output.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! Quick test to verify syntax highlighting works
|
||||||
|
//! Run with: cargo test -p g3-cli --test test_final_output -- --nocapture
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
// We'll directly test the syntax_highlight module's public function
|
||||||
|
// by importing it and calling it with a MadSkin
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_syntax_highlighting_visual() {
|
||||||
|
// Import what we need
|
||||||
|
use termimad::MadSkin;
|
||||||
|
|
||||||
|
// Create the test markdown
|
||||||
|
let test_markdown = r##"# Task Completed Successfully
|
||||||
|
|
||||||
|
Here's a summary of what was accomplished:
|
||||||
|
|
||||||
|
## Rust Code Example
|
||||||
|
|
||||||
|
Created a new function to handle user authentication:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Authenticates a user with the given credentials
|
||||||
|
pub async fn authenticate(username: &str, password: &str) -> Result<User, AuthError> {
|
||||||
|
let hash = hash_password(password)?;
|
||||||
|
|
||||||
|
if let Some(user) = db.find_user(username).await? {
|
||||||
|
if user.password_hash == hash {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(AuthError::InvalidPassword)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(AuthError::UserNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
Also added a Python script for data processing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
def process_data(items: List[Dict]) -> pd.DataFrame:
|
||||||
|
"""Process raw items into a cleaned DataFrame."""
|
||||||
|
df = pd.DataFrame(items)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
||||||
|
df = df.dropna(subset=['value'])
|
||||||
|
return df.sort_values('timestamp')
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript/TypeScript
|
||||||
|
|
||||||
|
Frontend component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserCard: React.FC<{ user: User }> = ({ user }) => {
|
||||||
|
return (
|
||||||
|
<div className="user-card">
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shell Commands
|
||||||
|
|
||||||
|
Deployment script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Building project..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
echo "Running tests..."
|
||||||
|
cargo test --all
|
||||||
|
|
||||||
|
echo "Deploying to production..."
|
||||||
|
rsync -avz ./target/release/app server:/opt/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"serde": "1.0",
|
||||||
|
"tokio": { "version": "1.0", "features": ["full"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Markdown Features
|
||||||
|
|
||||||
|
This section tests that **bold text**, *italic text*, and `inline code` still work correctly.
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item with **bold**
|
||||||
|
- Third item with `code`
|
||||||
|
|
||||||
|
### Numbered List
|
||||||
|
|
||||||
|
1. Step one
|
||||||
|
2. Step two
|
||||||
|
3. Step three
|
||||||
|
|
||||||
|
### Blockquote
|
||||||
|
|
||||||
|
> This is a blockquote that should be rendered
|
||||||
|
> with proper styling by termimad.
|
||||||
|
|
||||||
|
### Table
|
||||||
|
|
||||||
|
| Language | Extension | Use Case |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| Rust | .rs | Systems |
|
||||||
|
| Python | .py | Scripts |
|
||||||
|
| TypeScript | .ts | Frontend |
|
||||||
|
|
||||||
|
## Code Without Language
|
||||||
|
|
||||||
|
```
|
||||||
|
This is a code block without a language specified.
|
||||||
|
It should still be rendered as code, just without
|
||||||
|
syntax highlighting.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Notes
|
||||||
|
|
||||||
|
All changes have been tested and verified. The implementation:
|
||||||
|
|
||||||
|
- ✅ Handles multiple languages
|
||||||
|
- ✅ Preserves markdown formatting
|
||||||
|
- ✅ Works with nested structures
|
||||||
|
- ✅ Gracefully handles edge cases
|
||||||
|
"##;
|
||||||
|
|
||||||
|
// Create a styled markdown skin (same as in print_final_output)
|
||||||
|
let mut skin = MadSkin::default();
|
||||||
|
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
|
||||||
|
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
|
||||||
|
skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta);
|
||||||
|
skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta);
|
||||||
|
|
||||||
|
// Print header
|
||||||
|
println!("\n\x1b[1;35m━━━ Summary ━━━\x1b[0m\n");
|
||||||
|
|
||||||
|
// Use the syntax highlighting renderer
|
||||||
|
let rendered = g3_cli::syntax_highlight::render_markdown_with_highlighting(test_markdown, &skin);
|
||||||
|
print!("{}", rendered);
|
||||||
|
|
||||||
|
// Print footer
|
||||||
|
println!("\n\x1b[1;35m━━━━━━━━━━━━━━━\x1b[0m");
|
||||||
|
|
||||||
|
let _ = io::stdout().flush();
|
||||||
|
}
|
||||||
@@ -2027,6 +2027,9 @@ impl<W: UiWriter> Agent<W> {
|
|||||||
|
|
||||||
// Skip printing tool call details for final_output
|
// Skip printing tool call details for final_output
|
||||||
if tool_call.tool != "final_output" {
|
if tool_call.tool != "final_output" {
|
||||||
|
// Finish streaming markdown before showing tool output
|
||||||
|
self.ui_writer.finish_streaming_markdown();
|
||||||
|
|
||||||
// Tool call header
|
// Tool call header
|
||||||
self.ui_writer.print_tool_header(&tool_call.tool, Some(&tool_call.args));
|
self.ui_writer.print_tool_header(&tool_call.tool, Some(&tool_call.args));
|
||||||
if let Some(args_obj) = tool_call.args.as_object() {
|
if let Some(args_obj) = tool_call.args.as_object() {
|
||||||
@@ -2197,6 +2200,9 @@ impl<W: UiWriter> Agent<W> {
|
|||||||
|
|
||||||
// Check if this was a final_output tool call
|
// Check if this was a final_output tool call
|
||||||
if tool_call.tool == "final_output" {
|
if tool_call.tool == "final_output" {
|
||||||
|
// Finish the streaming markdown formatter before final_output
|
||||||
|
self.ui_writer.finish_streaming_markdown();
|
||||||
|
|
||||||
// Save context window BEFORE returning so the session log includes final_output
|
// Save context window BEFORE returning so the session log includes final_output
|
||||||
self.save_context_window("completed");
|
self.save_context_window("completed");
|
||||||
|
|
||||||
@@ -2406,6 +2412,9 @@ impl<W: UiWriter> Agent<W> {
|
|||||||
// Return empty string to avoid duplication
|
// Return empty string to avoid duplication
|
||||||
full_response = String::new();
|
full_response = String::new();
|
||||||
|
|
||||||
|
// Finish the streaming markdown formatter before returning
|
||||||
|
self.ui_writer.finish_streaming_markdown();
|
||||||
|
|
||||||
// Save context window BEFORE returning
|
// Save context window BEFORE returning
|
||||||
self.save_context_window("completed");
|
self.save_context_window("completed");
|
||||||
let _ttft =
|
let _ttft =
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ pub trait UiWriter: Send + Sync {
|
|||||||
/// Default implementation does nothing.
|
/// Default implementation does nothing.
|
||||||
fn reset_json_filter(&self) {}
|
fn reset_json_filter(&self) {}
|
||||||
|
|
||||||
|
/// Finish the streaming markdown formatter and flush any remaining content.
|
||||||
|
/// Called at the end of an agent response to emit any buffered markdown.
|
||||||
|
/// Also resets the formatter for the next response.
|
||||||
|
/// Default implementation does nothing.
|
||||||
|
fn finish_streaming_markdown(&self) {}
|
||||||
|
|
||||||
/// Set whether the UI is in agent mode.
|
/// Set whether the UI is in agent mode.
|
||||||
/// When in agent mode, tool names may be displayed differently (e.g., different color).
|
/// When in agent mode, tool names may be displayed differently (e.g., different color).
|
||||||
/// Default implementation does nothing.
|
/// Default implementation does nothing.
|
||||||
@@ -109,6 +115,7 @@ impl UiWriter for NullUiWriter {
|
|||||||
fn print_agent_response(&self, _content: &str) {}
|
fn print_agent_response(&self, _content: &str) {}
|
||||||
fn notify_sse_received(&self) {}
|
fn notify_sse_received(&self) {}
|
||||||
fn flush(&self) {}
|
fn flush(&self) {}
|
||||||
|
fn finish_streaming_markdown(&self) {}
|
||||||
fn wants_full_output(&self) -> bool {
|
fn wants_full_output(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user