M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
//! Render output types for markdown.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::buffers::CodeBlockMeta;
|
||||
|
||||
/// A hyperlink target extracted from rendered markdown.
|
||||
///
|
||||
/// Each instance maps a contiguous cell range on one rendered line to a URL.
|
||||
/// When a link wraps across lines, multiple `HyperlinkTarget`s share the same
|
||||
/// `id` and `url` -- the `id` enables OSC 8 hover-grouping across wrapped lines.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HyperlinkTarget {
|
||||
/// Index of the rendered line this target appears on.
|
||||
pub line_index: usize,
|
||||
/// Column range (in display cells) of the link text on that line.
|
||||
pub column_range: Range<usize>,
|
||||
/// The destination URL.
|
||||
pub url: String,
|
||||
/// Stable identifier for grouping link fragments that belong to the
|
||||
/// same logical link (e.g., a link whose text wraps across lines).
|
||||
pub id: u32,
|
||||
}
|
||||
|
||||
/// A fenced code block discovered while rendering markdown.
|
||||
///
|
||||
/// One `CodeBlockSpan` is produced per **closed** fenced code block, in
|
||||
/// document order. An unterminated (still-open) fence at the end of the input
|
||||
/// produces no span: `pulldown-cmark` synthesizes a block end at end-of-input,
|
||||
/// so closure is detected structurally (a closing fence must follow the body)
|
||||
/// rather than from the end event alone.
|
||||
///
|
||||
/// This is a generic, reusable description of a fenced block — it is not
|
||||
/// specific to any one info string (e.g. `mermaid`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodeBlockSpan {
|
||||
/// The fence info string, e.g. `"mermaid"` or `"rust"`.
|
||||
///
|
||||
/// Empty for a fence opened with no info (just ` ``` `). Reported verbatim
|
||||
/// as `pulldown-cmark` yields it (the full info string, not just the first
|
||||
/// word).
|
||||
pub info: String,
|
||||
|
||||
/// The fence body content — the clean, container-stripped code/diagram
|
||||
/// source.
|
||||
///
|
||||
/// This is `pulldown-cmark`'s merged body text, so container markers are
|
||||
/// removed (a blockquote `>` / list indentation does **not** leak in) and
|
||||
/// CRLF line endings are normalized to `\n`. It ends with the body's
|
||||
/// trailing newline and is empty for an empty-body fence. Prefer this over
|
||||
/// slicing [`source_byte_range`](Self::source_byte_range) when you need the
|
||||
/// logical body (e.g. a Mermaid diagram nested in a blockquote).
|
||||
pub body: String,
|
||||
|
||||
/// Range of **pre-wrap** rendered body lines for this block, as indices
|
||||
/// into [`MarkdownRenderOutput::lines`] / [`MarkdownRenderView::lines`].
|
||||
///
|
||||
/// Covers only the body — the delimiter ` ``` ` lines are excluded — so it
|
||||
/// is independent of whether the renderer hides those delimiters in pretty
|
||||
/// mode. Empty (`start == end`) for a fence with an empty body.
|
||||
pub output_line_range: Range<usize>,
|
||||
|
||||
/// Byte range of the fence body in the **raw** source text.
|
||||
///
|
||||
/// Spans from the first body byte to the last, with the delimiter fence
|
||||
/// lines excluded; empty (`start == end`) for an empty body. Unlike
|
||||
/// [`body`](Self::body) this is a raw slice, so for a fence nested in a
|
||||
/// blockquote or list it covers the source between the delimiters and may
|
||||
/// include container markers/indentation (and `\r` for CRLF) on
|
||||
/// continuation lines. Use [`body`](Self::body) for the clean content.
|
||||
pub source_byte_range: Range<usize>,
|
||||
}
|
||||
|
||||
/// Output from rendering markdown to ratatui Lines.
|
||||
///
|
||||
/// Contains all the information needed to display rendered markdown and
|
||||
/// support copy operations back to source text.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MarkdownRenderOutput {
|
||||
/// Rendered lines ready for display.
|
||||
pub lines: Vec<Line<'static>>,
|
||||
|
||||
/// Maps each rendered line index to its source line number.
|
||||
/// `line_source_map[rendered_line_idx]` = source line number (0-indexed).
|
||||
pub line_source_map: Vec<usize>,
|
||||
|
||||
/// Maps a cell range on a rendered line to a URL. Links that
|
||||
/// wrap across lines produce multiple entries with the same `id` and `url`.
|
||||
pub hyperlinks: Vec<HyperlinkTarget>,
|
||||
|
||||
/// Fenced code blocks discovered during rendering, in document order.
|
||||
/// One entry per closed fenced block; see [`CodeBlockSpan`].
|
||||
pub code_blocks: Vec<CodeBlockSpan>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderOutput {
|
||||
/// Create a new empty output.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Clear all content, keeping allocated capacity.
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
self.line_source_map.clear();
|
||||
self.hyperlinks.clear();
|
||||
self.code_blocks.clear();
|
||||
}
|
||||
|
||||
/// Get a borrowed view of this output.
|
||||
pub fn as_view(&self) -> MarkdownRenderView<'_> {
|
||||
MarkdownRenderView {
|
||||
lines: &self.lines,
|
||||
line_source_map: &self.line_source_map,
|
||||
hyperlinks: &self.hyperlinks,
|
||||
code_blocks: &self.code_blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed view of rendered markdown output.
|
||||
///
|
||||
/// This is a zero-copy reference to rendered content, used by the streaming
|
||||
/// renderer to avoid cloning frozen content on every render.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MarkdownRenderView<'a> {
|
||||
/// Rendered lines ready for display.
|
||||
pub lines: &'a [Line<'static>],
|
||||
|
||||
/// Maps each rendered line index to its source line number.
|
||||
pub line_source_map: &'a [usize],
|
||||
|
||||
/// Hyperlink targets extracted from the rendered markdown.
|
||||
pub hyperlinks: &'a [HyperlinkTarget],
|
||||
|
||||
/// Fenced code blocks discovered during rendering, in document order.
|
||||
/// One entry per closed fenced block; see [`CodeBlockSpan`].
|
||||
pub code_blocks: &'a [CodeBlockSpan],
|
||||
}
|
||||
|
||||
impl<'a> MarkdownRenderView<'a> {
|
||||
/// Get the number of lines.
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map parse-time code-block metadata onto the rendered output.
|
||||
///
|
||||
/// Runs after `render_ratatui` has produced `line_source_map`, turning each
|
||||
/// captured [`CodeBlockMeta`] into a public [`CodeBlockSpan`]. The
|
||||
/// pre-wrap body line range is derived from `line_source_map`: a fence body
|
||||
/// occupies source lines `[src_first, src_last]`, and because the renderer
|
||||
/// emits exactly one output line per body source line (and never maps a
|
||||
/// non-body line into that source-line range), the matching output lines form
|
||||
/// one contiguous run. `line_source_map` is non-decreasing, so the run is
|
||||
/// located with two `partition_point`s.
|
||||
///
|
||||
/// Cost is O(text_len + lines·log) per render: the metas are in ascending body
|
||||
/// order, so newline counts come from a single monotonic forward cursor over
|
||||
/// `text` rather than rescanning from byte 0 for every meta (which would be
|
||||
/// O(metas·text_len) — quadratic in the number of fences on the streaming hot
|
||||
/// path).
|
||||
pub(crate) fn build_code_block_spans(
|
||||
text: &str,
|
||||
line_source_map: &[usize],
|
||||
metas: Vec<CodeBlockMeta>,
|
||||
) -> Vec<CodeBlockSpan> {
|
||||
if metas.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let bytes = text.as_bytes();
|
||||
// Monotonic newline cursor. Each query advances from the previous position
|
||||
// (metas ascend by body offset), so the whole pass is O(text_len). '\n' is
|
||||
// single-byte ASCII, so byte counting is UTF-8-safe at any offset.
|
||||
let mut cursor_pos = 0usize;
|
||||
let mut cursor_newlines = 0usize;
|
||||
let mut newlines_before = |pos: usize| -> usize {
|
||||
let pos = pos.min(bytes.len());
|
||||
debug_assert!(
|
||||
pos >= cursor_pos,
|
||||
"metas must be processed in ascending body order",
|
||||
);
|
||||
while cursor_pos < pos {
|
||||
if bytes[cursor_pos] == b'\n' {
|
||||
cursor_newlines += 1;
|
||||
}
|
||||
cursor_pos += 1;
|
||||
}
|
||||
cursor_newlines
|
||||
};
|
||||
|
||||
metas
|
||||
.into_iter()
|
||||
.map(|meta| {
|
||||
let range = meta.body_source_range;
|
||||
let src_first = newlines_before(range.start);
|
||||
let output_line_range = if range.end <= range.start {
|
||||
// Empty body: no rendered body lines. Anchor an empty range at
|
||||
// the first output line that does not precede the body.
|
||||
let start = line_source_map.partition_point(|&src| src < src_first);
|
||||
start..start
|
||||
} else {
|
||||
// `range.end - 1` is the last body byte; its source line is the
|
||||
// inclusive last body source line, robust to a trailing newline.
|
||||
let src_last = newlines_before(range.end - 1);
|
||||
let start = line_source_map.partition_point(|&src| src < src_first);
|
||||
let end = line_source_map.partition_point(|&src| src <= src_last);
|
||||
start..end
|
||||
};
|
||||
CodeBlockSpan {
|
||||
info: meta.info,
|
||||
body: meta.body,
|
||||
output_line_range,
|
||||
source_byte_range: range,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod code_block_span_tests {
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::style::test_style::STYLE;
|
||||
use crate::{CodeBlockSpan, StreamingMarkdownRenderer, render_markdown_ratatui_full};
|
||||
|
||||
fn lines_text(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Plain text of the rendered body lines a span points at.
|
||||
fn body_lines(lines: &[Line<'static>], span: &CodeBlockSpan) -> Vec<String> {
|
||||
lines_text(&lines[span.output_line_range.clone()])
|
||||
}
|
||||
|
||||
/// Source bytes a span's `source_byte_range` selects.
|
||||
fn body_source<'a>(src: &'a str, span: &CodeBlockSpan) -> &'a str {
|
||||
&src[span.source_byte_range.clone()]
|
||||
}
|
||||
|
||||
fn blocks(src: &str, pretty: bool) -> (Vec<Line<'static>>, Vec<CodeBlockSpan>) {
|
||||
let (out, _) = render_markdown_ratatui_full(src, STYLE, pretty, None);
|
||||
(out.lines, out.code_blocks)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_fence_top_level_pretty_and_raw() {
|
||||
// A non-rendered language (`text`): its rendered body lines are the
|
||||
// verbatim source, so the span's `output_line_range` maps back to them.
|
||||
// (A `mermaid` fence renders to diagram art instead — see
|
||||
// `mermaid_fence_renders_inline_but_span_keeps_clean_source`.)
|
||||
let src = "```text\nflowchart TD\n A --> B\n```\n";
|
||||
for pretty in [true, false] {
|
||||
let (lines, cbs) = blocks(src, pretty);
|
||||
assert_eq!(cbs.len(), 1, "pretty={pretty}");
|
||||
assert_eq!(cbs[0].info, "text");
|
||||
// Body line range excludes the delimiter fences in both modes.
|
||||
assert_eq!(
|
||||
body_lines(&lines, &cbs[0]),
|
||||
vec!["flowchart TD", " A --> B"],
|
||||
"pretty={pretty}",
|
||||
);
|
||||
// Byte range and clean body are mode-independent; for a top-level
|
||||
// fence both equal the verbatim fence body.
|
||||
assert_eq!(body_source(src, &cbs[0]), "flowchart TD\n A --> B\n");
|
||||
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fence_produces_no_span() {
|
||||
// Unterminated fence at EOF: pulldown still emits a block end, but no
|
||||
// span must be produced (malformed/partial input).
|
||||
for src in [
|
||||
"```mermaid\nflowchart TD\n",
|
||||
"```mermaid\nflowchart TD",
|
||||
"```mermaid",
|
||||
"intro\n\n```rust\nlet x = 1;",
|
||||
] {
|
||||
for pretty in [true, false] {
|
||||
let (_, cbs) = blocks(src, pretty);
|
||||
assert!(
|
||||
cbs.is_empty(),
|
||||
"open fence {src:?} (pretty={pretty}) should yield no span, got {cbs:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_interspersed_blocks_in_order() {
|
||||
let src = "Intro\n\n```rust\nfn a() {}\n```\n\nMid prose\n\n```text\nA-->B\n```\n\nEnd\n";
|
||||
for pretty in [true, false] {
|
||||
let (lines, cbs) = blocks(src, pretty);
|
||||
assert_eq!(cbs.len(), 2, "pretty={pretty}");
|
||||
assert_eq!(cbs[0].info, "rust");
|
||||
assert_eq!(cbs[1].info, "text");
|
||||
assert_eq!(body_source(src, &cbs[0]), "fn a() {}\n");
|
||||
assert_eq!(body_source(src, &cbs[1]), "A-->B\n");
|
||||
assert_eq!(body_lines(&lines, &cbs[0]), vec!["fn a() {}"]);
|
||||
assert_eq!(body_lines(&lines, &cbs[1]), vec!["A-->B"]);
|
||||
// Document order ⇒ disjoint, increasing line ranges.
|
||||
assert!(cbs[0].output_line_range.end <= cbs[1].output_line_range.start);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_nested_in_list() {
|
||||
// Multi-line body so the list's base indent stripping is exercised on a
|
||||
// continuation line (" A --> B" → " A --> B").
|
||||
let src = "- item\n ```mermaid\n flowchart TD\n A --> B\n ```\n- next\n";
|
||||
for pretty in [true, false] {
|
||||
let (_lines, cbs) = blocks(src, pretty);
|
||||
assert_eq!(cbs.len(), 1, "pretty={pretty}");
|
||||
assert_eq!(cbs[0].info, "mermaid");
|
||||
// `body` is the clean, de-prefixed source: the list base indent is
|
||||
// stripped but inner relative indentation is preserved.
|
||||
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}");
|
||||
// The raw byte range, by contrast, also strips the per-line base
|
||||
// indent here (pulldown's text-event range starts after it).
|
||||
assert_eq!(
|
||||
body_source(src, &cbs[0]),
|
||||
"flowchart TD\n A --> B\n",
|
||||
"pretty={pretty}",
|
||||
);
|
||||
assert!(!cbs[0].output_line_range.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_nested_in_blockquote() {
|
||||
// The motivating case for the structural closure rule: the closing
|
||||
// fence line is "> ```" (not a bare fence), and the body must come out
|
||||
// de-prefixed (no leaked "> " / "│ ").
|
||||
let src = "> ```mermaid\n> flowchart TD\n> A --> B\n> ```\n";
|
||||
for pretty in [true, false] {
|
||||
let (_lines, cbs) = blocks(src, pretty);
|
||||
assert_eq!(cbs.len(), 1, "pretty={pretty}");
|
||||
assert_eq!(cbs[0].info, "mermaid");
|
||||
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_blockquote_fence_produces_no_span() {
|
||||
// Unterminated fence inside a blockquote ⇒ no span.
|
||||
let src = "> ```mermaid\n> flowchart TD\n";
|
||||
for pretty in [true, false] {
|
||||
let (_, cbs) = blocks(src, pretty);
|
||||
assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indented_code_block_is_not_a_fence() {
|
||||
// A 4-space indented code block is not a fenced block ⇒ no span, even
|
||||
// when its literal content looks like a fence.
|
||||
let src = "para\n\n ```mermaid\n A-->B\n ```\n";
|
||||
for pretty in [true, false] {
|
||||
let (_, cbs) = blocks(src, pretty);
|
||||
assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_closed_fence() {
|
||||
let src = "```mermaid\n```\n";
|
||||
// Pretty: both fence lines are hidden ⇒ no output lines ⇒ the empty
|
||||
// anchor lands at 0..0 (exact, not merely is_empty()).
|
||||
let (_, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].info, "mermaid");
|
||||
assert_eq!(cbs[0].output_line_range, 0..0);
|
||||
assert_eq!(body_source(src, &cbs[0]), "");
|
||||
assert_eq!(cbs[0].body, "");
|
||||
// Raw: both fence lines are shown ⇒ the empty body is anchored between
|
||||
// them at 1..1.
|
||||
let (_, cbs_raw) = blocks(src, false);
|
||||
assert_eq!(cbs_raw.len(), 1);
|
||||
assert_eq!(cbs_raw[0].output_line_range, 1..1);
|
||||
assert_eq!(cbs_raw[0].body, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_without_info_string() {
|
||||
let src = "```\nplain code\n```\n";
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].info, "");
|
||||
assert_eq!(body_lines(&lines, &cbs[0]), vec!["plain code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_fence_and_no_trailing_newline() {
|
||||
// Tilde delimiters and a closed fence at EOF without a final newline.
|
||||
for src in ["~~~text\nA-->B\n~~~\n", "```text\nfoo\n```"] {
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1, "{src:?}");
|
||||
assert_eq!(cbs[0].info, "text");
|
||||
assert_eq!(body_lines(&lines, &cbs[0]).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_inside_body_is_counted() {
|
||||
let src = "```text\nfoo\n\nbar\n```\n";
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(body_lines(&lines, &cbs[0]), vec!["foo", "", "bar"]);
|
||||
assert_eq!(body_source(src, &cbs[0]), "foo\n\nbar\n");
|
||||
assert_eq!(cbs[0].body, "foo\n\nbar\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_body_is_normalized_but_byte_range_retains_cr() {
|
||||
// CRLF: pulldown normalizes the body content to `\n`, while the raw
|
||||
// byte range still slices the `\r`. Line counting (over `\n`) is
|
||||
// unaffected.
|
||||
let src = "```text\r\nA-->B\r\n```\r\n";
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].body, "A-->B\n");
|
||||
assert_eq!(body_source(src, &cbs[0]), "A-->B\r\n");
|
||||
// One rendered body line (the renderer keeps the raw `\r`; `body` is the
|
||||
// normalized source of truth).
|
||||
assert_eq!(body_lines(&lines, &cbs[0]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_body_slices_safely() {
|
||||
// Multi-byte UTF-8 in the body: `source_byte_range` must land on char
|
||||
// boundaries (no slice panic) and `body` is the exact content.
|
||||
let src = "```text\nA --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n```\n";
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(
|
||||
cbs[0].body,
|
||||
"A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
body_source(src, &cbs[0]),
|
||||
"A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n",
|
||||
);
|
||||
assert_eq!(body_lines(&lines, &cbs[0]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_fence_with_inner_backticks() {
|
||||
// A 4-backtick fence whose body contains a ``` line must not close
|
||||
// early — one span whose body includes the inner fence text.
|
||||
let src = "````mermaid\n```\ninner\n```\n````\n";
|
||||
let (_lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].info, "mermaid");
|
||||
assert_eq!(cbs[0].body, "```\ninner\n```\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_in_body_is_preserved_by_crate() {
|
||||
// The markdown crate preserves a literal tab in the body (the pager
|
||||
// expands tabs before rendering; the crate itself does not).
|
||||
let src = "```mermaid\n\tA --> B\n```\n";
|
||||
let (_lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].body, "\tA --> B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_body_lines_match_span_body() {
|
||||
// The rendered (pre-wrap) body lines reconstruct to the span body
|
||||
// (sans the trailing newline a line-join drops). Both pretty and raw.
|
||||
let src = "```text\nflowchart TD\n A --> B\n B --> C\n```\n";
|
||||
for pretty in [true, false] {
|
||||
let (lines, cbs) = blocks(src, pretty);
|
||||
let joined = body_lines(&lines, &cbs[0]).join("\n");
|
||||
assert_eq!(
|
||||
joined, "flowchart TD\n A --> B\n B --> C",
|
||||
"pretty={pretty}"
|
||||
);
|
||||
assert_eq!(format!("{joined}\n"), cbs[0].body, "pretty={pretty}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mermaid_fence_renders_inline_but_span_keeps_clean_source() {
|
||||
// A closed ```mermaid fence is rendered inline by the markdown crate
|
||||
// (its body lines are replaced with diagram art), yet its CodeBlockSpan
|
||||
// still exposes the clean SOURCE via `body` — what the pager feeds the
|
||||
// PNG engine — and an `output_line_range` that spans the rendered
|
||||
// diagram, where the pager anchors its affordance row. This contract is
|
||||
// what the pager's Mermaid affordance row relies on.
|
||||
let src = "```mermaid\nflowchart TD\n A --> B\n```\n";
|
||||
let (lines, cbs) = blocks(src, true);
|
||||
assert_eq!(cbs.len(), 1);
|
||||
assert_eq!(cbs[0].info, "mermaid");
|
||||
// `body` is the verbatim diagram source, independent of rendering.
|
||||
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n");
|
||||
// The fence is rendered inline: the spanned output lines are the diagram
|
||||
// art, not the verbatim source.
|
||||
assert!(!cbs[0].output_line_range.is_empty());
|
||||
let rendered = body_lines(&lines, &cbs[0]).join("\n");
|
||||
assert_ne!(rendered, "flowchart TD\n A --> B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_span_stable_once_frozen() {
|
||||
let full = "Intro\n\n```mermaid\nA-->B\nC-->D\n```\n\nAfter the block.\n\nMore prose.\n";
|
||||
|
||||
let mut renderer = StreamingMarkdownRenderer::new(STYLE, true);
|
||||
let mut frozen_span: Option<CodeBlockSpan> = None;
|
||||
for ch in full.chars() {
|
||||
renderer.push_and_render(&ch.to_string(), None);
|
||||
let view = renderer.view();
|
||||
let frozen_lines = renderer.frozen_lines_count();
|
||||
if let Some(cb) = view.code_blocks.iter().find(|c| c.info == "mermaid") {
|
||||
// Only assert stability once the block is within frozen content.
|
||||
if cb.output_line_range.end <= frozen_lines {
|
||||
match &frozen_span {
|
||||
None => frozen_span = Some(cb.clone()),
|
||||
Some(prev) => {
|
||||
assert_eq!(prev, cb, "frozen mermaid span changed across pushes",)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let frozen_span = frozen_span.expect("mermaid block should freeze mid-stream");
|
||||
|
||||
// Streaming output (after finish) must match a one-shot full render.
|
||||
let finished = renderer.finish(None);
|
||||
let streamed = finished
|
||||
.code_blocks
|
||||
.iter()
|
||||
.find(|c| c.info == "mermaid")
|
||||
.expect("finish() keeps the mermaid span");
|
||||
assert_eq!(&frozen_span, streamed);
|
||||
|
||||
let (full_out, _) = render_markdown_ratatui_full(full, STYLE, true, None);
|
||||
let full_cb = full_out
|
||||
.code_blocks
|
||||
.iter()
|
||||
.find(|c| c.info == "mermaid")
|
||||
.expect("full render finds the mermaid span");
|
||||
assert_eq!(&frozen_span, full_cb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_open_fence_has_no_frozen_span() {
|
||||
// While the fence is still open, any transient span must remain in the
|
||||
// unfrozen tail (never within the frozen prefix).
|
||||
let mut renderer = StreamingMarkdownRenderer::new(STYLE, true);
|
||||
for chunk in ["intro\n\n", "```mermaid\n", "flowchart TD\n", "A --> B\n"] {
|
||||
renderer.push_and_render(chunk, None);
|
||||
let frozen_lines = renderer.frozen_lines_count();
|
||||
let view = renderer.view();
|
||||
for cb in view.code_blocks {
|
||||
assert!(
|
||||
cb.output_line_range.end > frozen_lines,
|
||||
"open fence span must not be frozen: {cb:?} frozen_lines={frozen_lines}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_chunked_matches_full_render() {
|
||||
let full = "# Title\n\n```mermaid\nA-->B\n```\n\ntext\n\n```rust\nfn f() {}\n```\n\nbye\n";
|
||||
for pretty in [true, false] {
|
||||
let mut renderer = StreamingMarkdownRenderer::new(STYLE, pretty);
|
||||
// Irregular chunk boundaries to exercise tail rebasing.
|
||||
let bytes = full.as_bytes();
|
||||
let mut pos = 0;
|
||||
for step in [4usize, 9, 1, 13, 7].iter().cycle() {
|
||||
if pos >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
let mut end = (pos + step).min(bytes.len());
|
||||
while end < bytes.len() && !full.is_char_boundary(end) {
|
||||
end += 1;
|
||||
}
|
||||
renderer.push_and_render(&full[pos..end], None);
|
||||
pos = end;
|
||||
}
|
||||
let view = renderer.finish(None);
|
||||
let streamed: Vec<_> = view.code_blocks.to_vec();
|
||||
|
||||
let (full_out, _) = render_markdown_ratatui_full(full, STYLE, pretty, None);
|
||||
assert_eq!(
|
||||
streamed, full_out.code_blocks,
|
||||
"pretty={pretty}: chunked stream must match full render",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user