fix(markdown): preserve underscores inside identifiers when rendering inline spans

Before this change, the inline markdown parser greedily matched the
underscore in identifiers like `deepseek_tui` or `foo_bar_baz` against
the `_italic_` pattern, so the second half of the identifier rendered
in italic and transcript snippets that quoted code symbols read as
garbled prose. The same bug applied to `*italic*` against tokens like
`look_at*tail`.

Both italic delimiters now apply a CommonMark-style boundary check on
the closing run: when the character immediately after the closing
`_` / `*` is a letter, digit, or underscore, the delimiter is left
as literal text rather than treated as markup. Identifiers survive
intact; legitimate emphasis still renders.

Regression-pinned with `crate deepseek_tui handles approvals`,
`see foo_bar_baz for details`, and `look at *not_emphasised*tail`.

Harvested from PR #1455 by @tiger-dog

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Hunter BownandClaude Opus 4.7 committed 2026-05-12 00:28:10 -05:00
1 parent 1f0065ccee
commit 02f889f193
2 files changed
+64 -6

No files matched your search

+14
View File
@@ -14,6 +14,20 @@ have to work with?" — and the answer is now closer to "everything
you'd reach for from a shell, including the document formats the
real world uses."
### Fixed
- **Markdown renderer no longer eats underscores inside
identifiers** (harvested from PR #1455 by **@tiger-dog**). The
inline parser was matching `_italic_` against the underscore in
`deepseek_tui` / `foo_bar_baz` and rendering the second half of
the identifier in italic, which made transcript snippets that
named code symbols read as garbled prose. Both `_italic_` and
`*italic*` now apply a CommonMark-style boundary check on the
closing delimiter — when the next character is a letter, digit,
or underscore, the delimiter is treated as literal text instead
of markup. Regression-pinned with cases like `crate deepseek_tui
handles approvals` and `look at *not_emphasised*tail`.
### Added
- **`/change` slash command** displays the most recent
+50 -6
View File
@@ -566,9 +566,15 @@ fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(
&& let Some(end) = rest[1..].find('*')
{
let inner = &rest[1..1 + end];
out.push((inner.to_string(), italic_style));
rest = &rest[1 + end + 1..];
continue;
let after = &rest[1 + end + 1..];
// Closing delimiter must not be immediately followed by a
// letter, digit, or underscore (otherwise it's part of an
// identifier like `deepseek_tui`, not italic markup).
if !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
out.push((inner.to_string(), italic_style));
rest = after;
continue;
}
}
// _italic_
if rest.starts_with('_')
@@ -576,9 +582,14 @@ fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(
&& let Some(end) = rest[1..].find('_')
{
let inner = &rest[1..1 + end];
out.push((inner.to_string(), italic_style));
rest = &rest[1 + end + 1..];
continue;
let after = &rest[1 + end + 1..];
// Closing delimiter must not be immediately followed by a
// letter, digit, or underscore.
if !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
out.push((inner.to_string(), italic_style));
rest = after;
continue;
}
}
// `inline code`
if let Some(end) = rest.strip_prefix('`').and_then(|s| s.find('`')) {
@@ -985,6 +996,39 @@ mod tests {
use super::*;
use ratatui::style::Style;
#[test]
fn underscores_inside_identifiers_render_as_literal_text() {
// Regression for PR #1455 / @tiger-dog: previously the inline
// markdown parser ate the underscore in `deepseek_tui` because
// it matched the `_italic_` pattern without a CommonMark-style
// boundary check. The closing `_` followed by `t` (a letter)
// must now be treated as part of the identifier, not as
// markup. The same rule applies to `*` so identifiers like
// `crate*foo` round-trip cleanly.
let cases = [
"crate deepseek_tui handles approvals",
"see foo_bar_baz for details",
"look at *not_emphasised*tail",
];
for source in cases {
let parsed = parse(source);
let rendered: String = render_parsed(&parsed, 80, Style::default())
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect();
// The original identifier (with underscores intact) must
// appear in the rendered output. We don't assert on style
// here — that's an implementation detail; we assert on
// the user-visible character sequence.
for token in source.split_whitespace().filter(|t| t.contains('_')) {
assert!(
rendered.contains(token),
"identifier {token:?} must survive markdown rendering of {source:?}; got {rendered:?}"
);
}
}
}
#[test]
fn render_markdown_matches_parse_then_render() {
// Both calls run in the same thread under the same OSC8 lock so the