p1-3: update kb-parse-md callers + drop BlockView projection in snapshots

Mechanical sweep over `Inline::Text(_)` / `Code(_)` / `Strong(_)` / `Emph(_)`
construction and match sites under the new struct-variant shape introduced
in the previous commit. `Inline::Link { text, href }` is unchanged.

The snapshot test in `tests/blocks_snapshots.rs` previously projected
`ParsedBlock` into a `BlockView`/`PayloadView` shim because the old
`Inline` could not serialize. With the schema fix in place we now
serialize `ParsedBlock` directly through serde — the shim and its
`flatten_inline` helper are removed. Inlines surface as structured
objects (`{"kind":"text","text":"…"}` etc.). Regenerated
`nested-headings.blocks.snapshot.json` to reflect the new shape via
the existing `--ignored` emitter; `code-and-table.blocks.snapshot.json`
has no inlines and is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 15:10:54 +00:00
parent 606ce1cf66
commit cfccb3687d
3 changed files with 51 additions and 160 deletions

View File

@@ -300,12 +300,12 @@ impl InlineBuf {
fn push_text(&mut self, s: &str) {
self.text.push_str(s);
self.push_inline(Inline::Text(s.to_string()));
self.push_inline(Inline::Text { text: s.to_string() });
}
fn push_code(&mut self, s: &str) {
self.text.push_str(s);
self.push_inline(Inline::Code(s.to_string()));
self.push_inline(Inline::Code { code: s.to_string() });
}
fn open_strong(&mut self) {
@@ -313,7 +313,7 @@ impl InlineBuf {
}
fn close_strong(&mut self) {
if let Some(InlineFrame::Strong(kids)) = self.stack.pop() {
self.push_inline(Inline::Strong(kids));
self.push_inline(Inline::Strong { children: kids });
}
}
@@ -322,7 +322,7 @@ impl InlineBuf {
}
fn close_emph(&mut self) {
if let Some(InlineFrame::Emph(kids)) = self.stack.pop() {
self.push_inline(Inline::Emph(kids));
self.push_inline(Inline::Emph { children: kids });
}
}
@@ -361,8 +361,8 @@ impl InlineBuf {
// If formatting tags were unbalanced we close them defensively.
while self.stack.len() > 1 {
match self.stack.pop().unwrap() {
InlineFrame::Strong(kids) => self.push_inline(Inline::Strong(kids)),
InlineFrame::Emph(kids) => self.push_inline(Inline::Emph(kids)),
InlineFrame::Strong(kids) => self.push_inline(Inline::Strong { children: kids }),
InlineFrame::Emph(kids) => self.push_inline(Inline::Emph { children: kids }),
InlineFrame::Link { href, text, kids } => {
let flat = if !text.is_empty() {
text
@@ -475,10 +475,11 @@ fn flatten_inlines_to_text(inlines: &[Inline]) -> String {
fn flatten_one(i: &Inline, out: &mut String) {
match i {
Inline::Text(s) | Inline::Code(s) => out.push_str(s),
Inline::Text { text } => out.push_str(text),
Inline::Code { code } => out.push_str(code),
Inline::Link { text, .. } => out.push_str(text),
Inline::Strong(v) | Inline::Emph(v) => {
for c in v {
Inline::Strong { children } | Inline::Emph { children } => {
for c in children {
flatten_one(c, out);
}
}
@@ -823,7 +824,7 @@ impl<'a> WalkState<'a> {
text.push('\n');
}
text.push_str(t);
inlines.push(Inline::Text(t.clone()));
inlines.push(Inline::Text { text: t.clone() });
}
_ => {}
}
@@ -921,7 +922,7 @@ impl<'a> WalkState<'a> {
source_span: self.span_for(&range),
payload: ParsedPayload::Paragraph {
text: raw.clone(),
inlines: vec![Inline::Text(raw)],
inlines: vec![Inline::Text { text: raw }],
},
}
} else {
@@ -1477,7 +1478,7 @@ mod tests {
assert!(
matches!(
inl,
Inline::Text(_) | Inline::Code(_) | Inline::Link { .. } | Inline::Strong(_) | Inline::Emph(_)
Inline::Text { .. } | Inline::Code { .. } | Inline::Link { .. } | Inline::Strong { .. } | Inline::Emph { .. }
),
"unexpected inline kind: {:?}",
inl
@@ -1736,11 +1737,11 @@ mod tests {
match &blocks[0].payload {
ParsedPayload::Paragraph { inlines, .. } => {
let kinds: Vec<&'static str> = inlines.iter().map(|i| match i {
Inline::Text(_) => "Text",
Inline::Code(_) => "Code",
Inline::Text { .. } => "Text",
Inline::Code { .. } => "Code",
Inline::Link { .. } => "Link",
Inline::Strong(_) => "Strong",
Inline::Emph(_) => "Emph",
Inline::Strong { .. } => "Strong",
Inline::Emph { .. } => "Emph",
}).collect();
assert!(kinds.contains(&"Strong"));
assert!(kinds.contains(&"Emph"));

View File

@@ -4,19 +4,13 @@
//! below. `body_offset_lines = 1` is used for both fixtures (no
//! frontmatter, body starts at file line 1).
//!
//! Note on snapshot shape: `kb_core::Inline` carries a `serde(tag = "kind")`
//! enum representation that cannot serialize newtype variants holding a
//! primitive (`Inline::Text(String)` etc.) — that's a serde limitation, not
//! ours, and is fixed up in a later kb-core task. To keep the snapshot
//! human-readable (and stable across that future fix), we project each
//! `ParsedBlock` into a `BlockView` that flattens inline content to plain
//! strings before serialization. This still pins the *contract* that
//! matters for P1-3: heading paths, source spans, payload kinds, payload
//! text content, table headers/rows, and code lang/body.
//! Following the kb_core::Inline schema migration (struct-variant shape),
//! `ParsedBlock` now serializes directly through serde — no projection
//! shim is required. Inlines surface as structured objects, e.g.
//! `[{"kind":"text","text":"…"},{"kind":"code","code":"…"}]`.
use kb_core::{Inline, SourceSpan};
use kb_parse_md::parse_blocks;
use kb_parse_types::{ParsedBlock, ParsedPayload, Warning};
use kb_parse_types::{ParsedBlock, Warning};
use serde::Serialize;
use serde_json::Value;
use std::fs;
@@ -24,130 +18,10 @@ use std::path::PathBuf;
#[derive(Serialize)]
struct Snapshot {
blocks: Vec<BlockView>,
blocks: Vec<ParsedBlock>,
warnings: Vec<Warning>,
}
#[derive(Serialize)]
struct BlockView {
kind: String,
heading_path: Vec<String>,
source_span: SourceSpan,
payload: PayloadView,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum PayloadView {
Heading {
level: u8,
text: String,
},
Paragraph {
text: String,
inlines_flat: String,
},
List {
ordered: bool,
items_flat: Vec<String>,
},
Code {
lang: Option<String>,
code: String,
},
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
Quote {
text: String,
inlines_flat: String,
},
ImageRef {
src: String,
alt: String,
},
AudioRef {
src: String,
},
}
fn flatten_inline(i: &Inline, out: &mut String) {
match i {
Inline::Text(s) | Inline::Code(s) => out.push_str(s),
Inline::Link { text, href } => {
out.push('[');
out.push_str(text);
out.push_str("](");
out.push_str(href);
out.push(')');
}
Inline::Strong(v) => {
out.push_str("**");
for c in v {
flatten_inline(c, out);
}
out.push_str("**");
}
Inline::Emph(v) => {
out.push('*');
for c in v {
flatten_inline(c, out);
}
out.push('*');
}
}
}
fn flatten(inlines: &[Inline]) -> String {
let mut out = String::new();
for i in inlines {
flatten_inline(i, &mut out);
}
out
}
fn block_to_view(b: &ParsedBlock) -> BlockView {
let kind = format!("{:?}", b.kind).to_lowercase();
let payload = match &b.payload {
ParsedPayload::Heading { level, text } => PayloadView::Heading {
level: *level,
text: text.clone(),
},
ParsedPayload::Paragraph { text, inlines } => PayloadView::Paragraph {
text: text.clone(),
inlines_flat: flatten(inlines),
},
ParsedPayload::List { ordered, items } => PayloadView::List {
ordered: *ordered,
items_flat: items.iter().map(|it| flatten(it)).collect(),
},
ParsedPayload::Code { lang, code } => PayloadView::Code {
lang: lang.clone(),
code: code.clone(),
},
ParsedPayload::Table { headers, rows } => PayloadView::Table {
headers: headers.clone(),
rows: rows.clone(),
},
ParsedPayload::Quote { text, inlines } => PayloadView::Quote {
text: text.clone(),
inlines_flat: flatten(inlines),
},
ParsedPayload::ImageRef { src, alt } => PayloadView::ImageRef {
src: src.clone(),
alt: alt.clone(),
},
ParsedPayload::AudioRef { src } => PayloadView::AudioRef { src: src.clone() },
};
BlockView {
kind,
heading_path: b.heading_path.clone(),
source_span: b.source_span.clone(),
payload,
}
}
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
@@ -162,7 +36,7 @@ fn assert_snapshot(fixture: &str, baseline: &str) {
let (blocks, warns) = parse_blocks(&bytes, 1).unwrap();
let snap = Snapshot {
blocks: blocks.iter().map(block_to_view).collect(),
blocks,
warnings: warns,
};
let actual: Value = serde_json::to_value(&snap).unwrap();
@@ -211,7 +85,7 @@ fn emit_blocks_snapshots() {
let bytes = fs::read(dir.join(fixture)).unwrap();
let (blocks, warns) = parse_blocks(&bytes, 1).unwrap();
let snap = Snapshot {
blocks: blocks.iter().map(block_to_view).collect(),
blocks,
warnings: warns,
};
let json = serde_json::to_string_pretty(&snap).unwrap();
@@ -227,14 +101,10 @@ fn snapshot_is_deterministic_across_runs() {
let bytes = fs::read(dir.join("nested-headings.md")).unwrap();
let (a_blocks, a_warns) = parse_blocks(&bytes, 1).unwrap();
let (b_blocks, b_warns) = parse_blocks(&bytes, 1).unwrap();
// Compare via the view (which is fully serializable) and via the
// structural equality on `ParsedBlock` itself (no serde involved).
assert_eq!(a_blocks, b_blocks);
assert_eq!(a_warns, b_warns);
let av: Vec<_> = a_blocks.iter().map(block_to_view).collect();
let bv: Vec<_> = b_blocks.iter().map(block_to_view).collect();
assert_eq!(
serde_json::to_value(&av).unwrap(),
serde_json::to_value(&bv).unwrap()
serde_json::to_value(&a_blocks).unwrap(),
serde_json::to_value(&b_blocks).unwrap()
);
}

View File

@@ -27,7 +27,12 @@
"payload": {
"kind": "paragraph",
"text": "intro",
"inlines_flat": "intro"
"inlines": [
{
"kind": "text",
"text": "intro"
}
]
}
},
{
@@ -60,7 +65,12 @@
"payload": {
"kind": "paragraph",
"text": "body of A",
"inlines_flat": "body of A"
"inlines": [
{
"kind": "text",
"text": "body of A"
}
]
}
},
{
@@ -95,7 +105,12 @@
"payload": {
"kind": "paragraph",
"text": "deeper",
"inlines_flat": "deeper"
"inlines": [
{
"kind": "text",
"text": "deeper"
}
]
}
},
{
@@ -128,7 +143,12 @@
"payload": {
"kind": "paragraph",
"text": "body of B",
"inlines_flat": "body of B"
"inlines": [
{
"kind": "text",
"text": "body of B"
}
]
}
}
],