mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## Why Transcript selections were copied as plain text, losing the formatting of rendered Markdown when pasted. ## What changed - Retain semantic Markdown annotations through rendering, wrapping, and streaming, then serialize selected text with balanced markup. - Preserve inline formatting, web links, headings, lists, blockquotes, and hard breaks in Markdown clipboard payloads. - Keep code-only and other literal-only selections as plain text. In mixed selections, escape user prose and fence code, tables, and tool output to preserve their literal content. ## Testing Add unit tests and Markdown/HTML snapshots covering partial selections, nested containers, escaping, links, resize stability, and streaming boundaries. GitOrigin-RevId: f0476334a6bf8ad0fb57626627bd258443540852
141 lines
5.5 KiB
Rust
141 lines
5.5 KiB
Rust
//! A conservative fast path for one open, top-level, language-tagged code fence.
|
|
|
|
use crate::markdown_copy::CopyLine;
|
|
use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES;
|
|
use crate::render::highlight::StreamingCodeHighlighter;
|
|
use crate::render::highlight::syntax_theme_revision;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
use crate::terminal_hyperlinks::LogicalLineSource;
|
|
use ratatui::text::Span;
|
|
use std::sync::Arc;
|
|
|
|
/// An append-only fence whose original source is identical to pulldown-cmark's code text.
|
|
pub(super) struct OpenCodeFence {
|
|
marker: u8,
|
|
marker_len: usize,
|
|
language: String,
|
|
content_start: usize,
|
|
source_len: usize,
|
|
theme_revision: u64,
|
|
/// Initialized only if another chunk arrives, avoiding duplicate work on one-shot fences.
|
|
highlighter: Option<StreamingCodeHighlighter>,
|
|
}
|
|
|
|
impl OpenCodeFence {
|
|
/// Recognize the final mutable top-level block after its canonical render.
|
|
///
|
|
/// `source` is the final block's suffix, while `source_len` includes the entire committed
|
|
/// Markdown source and anchors the retained fence within it.
|
|
///
|
|
/// Indentation, CR/NUL normalization, and escaped info strings stay on the canonical path.
|
|
/// Any line that could end the fence also stays there, including deliberately conservative
|
|
/// false positives, so this fast path need not duplicate CommonMark's closing-fence grammar.
|
|
pub(super) fn detect(source: &str, source_len: usize, theme_revision: u64) -> Option<Self> {
|
|
let marker = *source.as_bytes().first()?;
|
|
if marker != b'`' && marker != b'~' {
|
|
return None;
|
|
}
|
|
let (opening, code) = source.split_once('\n')?;
|
|
let marker_len = opening.bytes().take_while(|byte| *byte == marker).count();
|
|
if marker_len < 3
|
|
|| !source.ends_with('\n')
|
|
|| theme_revision != syntax_theme_revision()
|
|
|| source.contains(['\r', '\0'])
|
|
{
|
|
return None;
|
|
}
|
|
let info = opening[marker_len..].trim_matches([' ', '\t', '\u{b}', '\u{c}']);
|
|
if info.contains(['&', '\\']) || (marker == b'`' && info.contains('`')) {
|
|
return None;
|
|
}
|
|
let language = info
|
|
.split([',', ' ', '\t'])
|
|
.next()
|
|
.filter(|language| !language.is_empty())?;
|
|
// Markdown fences may gain visible delimiters when a disabled table is recognized.
|
|
if !crate::markdown_render::preferences::current().tables
|
|
&& crate::table_detect::is_markdown_fence_info(info, /*marker_len*/ 0)
|
|
{
|
|
return None;
|
|
}
|
|
// Retain at most one bounded language token, never the streamed code itself.
|
|
if language.len() > MAX_HIGHLIGHT_LINE_BYTES
|
|
|| has_possible_closing_line(code, marker, marker_len)
|
|
{
|
|
return None;
|
|
}
|
|
Some(Self {
|
|
marker,
|
|
marker_len,
|
|
language: language.to_string(),
|
|
content_start: source_len.checked_sub(code.len())?,
|
|
source_len,
|
|
theme_revision,
|
|
highlighter: None,
|
|
})
|
|
}
|
|
|
|
/// Append code only while the caller's newline-committed source extends this same fence.
|
|
///
|
|
/// Returning `None` consumes the retained state and requires a canonical whole-fence render.
|
|
/// Successful lines include the empty indent span expected by the canonical Markdown writer.
|
|
pub(super) fn append(
|
|
mut self,
|
|
raw_source: &str,
|
|
committed_source: &str,
|
|
) -> Option<(Self, Vec<HyperlinkLine>)> {
|
|
if self.source_len.checked_add(committed_source.len()) != Some(raw_source.len())
|
|
|| !raw_source.ends_with(committed_source)
|
|
|| committed_source.contains(['\r', '\0'])
|
|
|| has_possible_closing_line(committed_source, self.marker, self.marker_len)
|
|
|| self.theme_revision != syntax_theme_revision()
|
|
{
|
|
return None;
|
|
}
|
|
let highlighter = match self.highlighter.take() {
|
|
Some(highlighter) => highlighter,
|
|
None => StreamingCodeHighlighter::new(
|
|
&raw_source[self.content_start..self.source_len],
|
|
&self.language,
|
|
self.theme_revision,
|
|
)?,
|
|
};
|
|
let (highlighter, lines) = highlighter.append(committed_source)?;
|
|
self.highlighter = Some(highlighter);
|
|
self.source_len = raw_source.len();
|
|
let mut copy = CopyLine::default();
|
|
copy.code = true;
|
|
let copy = Arc::new(copy);
|
|
let lines = lines
|
|
.into_iter()
|
|
.map(|mut line| {
|
|
let mut source = LogicalLineSource::from_line(&line);
|
|
source.copy = Some(Arc::clone(©));
|
|
// The canonical writer installs an empty indent span for top-level fences.
|
|
line.spans.insert(/*index*/ 0, Span::default());
|
|
let mut line = HyperlinkLine::new(line);
|
|
line.source = Some(source);
|
|
line
|
|
})
|
|
.collect();
|
|
Some((self, lines))
|
|
}
|
|
}
|
|
|
|
/// Conservatively detect any line that might close the fence.
|
|
///
|
|
/// False positives intentionally fall back to CommonMark instead of duplicating its closer rules.
|
|
fn has_possible_closing_line(source: &str, marker: u8, marker_len: usize) -> bool {
|
|
source.lines().any(|line| {
|
|
line.trim_start_matches([' ', '\t'])
|
|
.bytes()
|
|
.take_while(|byte| *byte == marker)
|
|
.count()
|
|
>= marker_len
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "code_fence_tests.rs"]
|
|
mod tests;
|