mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 18:17:39 -10:00
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:
1 parent
1f0065ccee
commit
02f889f193
2 files changed
+64
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user