mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
156 lines
5.9 KiB
Rust
156 lines
5.9 KiB
Rust
use codex_core::config::Config;
|
||
use codex_core::config::UriBasedFileOpener;
|
||
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
|
||
use crate::citation_regex::CITATION_REGEX;
|
||
|
||
/// Convert the provided markdown to [`ratatui`] [`Line`]s and append them to
|
||
/// `lines`. Before rendering we rewrite Codex-style file citations of the form
|
||
/// `【F:path/to/file†L10-L20】` into standard markdown hyperlinks that IDEs such
|
||
/// as VS Code can interpret when combined with the "URI-based file opener"
|
||
/// functionality.
|
||
///
|
||
/// The specific URI scheme to use is determined by `file_opener` (e.g.
|
||
/// `vscode`, `vscode-insiders`, `cursor`, `windsurf`). When it is `None` the
|
||
/// citations are **left unchanged** so they still render as plain text.
|
||
pub(crate) fn append_markdown(
|
||
markdown_source: &str,
|
||
lines: &mut Vec<Line<'static>>,
|
||
config: &Config,
|
||
) {
|
||
// Perform citation rewrite *before* feeding the string to the markdown
|
||
// renderer. When `file_opener` is absent we bypass the transformation to
|
||
// avoid unnecessary allocations.
|
||
let processed_markdown: std::borrow::Cow<'_, str> = if let Some(scheme) = config.file_opener {
|
||
std::borrow::Cow::Owned(rewrite_file_citations(markdown_source, scheme, &config.cwd))
|
||
} else {
|
||
std::borrow::Cow::Borrowed(markdown_source)
|
||
};
|
||
|
||
let markdown = tui_markdown::from_str(&processed_markdown);
|
||
|
||
// `tui_markdown` returns a `ratatui::text::Text` where every `Line` borrows
|
||
// from the input `message` string. Since the `HistoryCell` stores its lines
|
||
// with a `'static` lifetime we must create an **owned** copy of each line
|
||
// so that it is no longer tied to `message`. We do this by cloning the
|
||
// content of every `Span` into an owned `String`.
|
||
|
||
for borrowed_line in markdown.lines {
|
||
let mut owned_spans = Vec::with_capacity(borrowed_line.spans.len());
|
||
for span in &borrowed_line.spans {
|
||
// Create a new owned String for the span's content to break the lifetime link.
|
||
let owned_span = Span::styled(span.content.to_string(), span.style);
|
||
owned_spans.push(owned_span);
|
||
}
|
||
|
||
let owned_line: Line<'static> = Line::from(owned_spans).style(borrowed_line.style);
|
||
// Preserve alignment if it was set on the source line.
|
||
let owned_line = match borrowed_line.alignment {
|
||
Some(alignment) => owned_line.alignment(alignment),
|
||
None => owned_line,
|
||
};
|
||
|
||
lines.push(owned_line);
|
||
}
|
||
}
|
||
|
||
/// Rewrites file citations in `src` into markdown hyperlinks using the
|
||
/// provided `scheme` (`vscode`, `cursor`, etc.). The resulting URI follows the
|
||
/// format expected by VS Code-compatible file openers:
|
||
///
|
||
/// ```text
|
||
/// <scheme>://file<ABS_PATH>:<LINE>
|
||
/// ```
|
||
fn rewrite_file_citations(src: &str, scheme: UriBasedFileOpener, cwd: &Path) -> String {
|
||
// Map enum values to the corresponding URI scheme strings.
|
||
let scheme_str: &str = match scheme {
|
||
UriBasedFileOpener::VsCode => "vscode",
|
||
UriBasedFileOpener::VsCodeInsiders => "vscode-insiders",
|
||
UriBasedFileOpener::Windsurf => "windsurf",
|
||
UriBasedFileOpener::Cursor => "cursor",
|
||
};
|
||
|
||
CITATION_REGEX
|
||
.replace_all(src, |caps: ®ex::Captures<'_>| {
|
||
let file = &caps[1];
|
||
let start_line = &caps[2];
|
||
|
||
// Resolve the path against `cwd` when it is relative.
|
||
let path = {
|
||
let p = Path::new(file);
|
||
if p.is_absolute() {
|
||
PathBuf::from(p)
|
||
} else {
|
||
cwd.join(p)
|
||
}
|
||
};
|
||
|
||
// VS Code expects forward slashes even on Windows because URIs use
|
||
// `/` as the path separator.
|
||
let abs = path.to_string_lossy().replace('\\', "/");
|
||
|
||
// Render as a normal markdown link so the downstream renderer emits
|
||
// the hyperlink escape sequence (when supported by the terminal).
|
||
format!("[{}]({}://file{}:{})", file, scheme_str, abs, start_line)
|
||
})
|
||
.into_owned()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
#[test]
|
||
fn citation_is_rewritten_with_absolute_path() {
|
||
let markdown = "See 【F:/src/main.rs†L42-L50】 for details.";
|
||
let cwd = Path::new("/workspace");
|
||
let result = rewrite_file_citations(markdown, UriBasedFileOpener::VsCode, cwd);
|
||
|
||
assert_eq!(
|
||
"See [/src/main.rs](vscode://file/src/main.rs:42) for details.",
|
||
result
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn citation_is_rewritten_with_relative_path() {
|
||
let markdown = "Refer to 【F:lib/mod.rs†L5】 here.";
|
||
let cwd = Path::new("/home/user/project");
|
||
let result = rewrite_file_citations(markdown, UriBasedFileOpener::Cursor, cwd);
|
||
|
||
assert_eq!(
|
||
"Refer to [lib/mod.rs](cursor://file/home/user/project/lib/mod.rs:5) here.",
|
||
result
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn citation_unchanged_without_file_opener() {
|
||
let markdown = "Look at 【F:file.rs†L1】.";
|
||
let cwd = Path::new("/");
|
||
let unchanged = rewrite_file_citations(markdown, UriBasedFileOpener::VsCode, cwd);
|
||
// The helper itself always rewrites – this test validates behaviour of
|
||
// append_markdown when `file_opener` is None.
|
||
let mut out = Vec::new();
|
||
append_markdown(markdown, &mut out, None, cwd);
|
||
// Convert lines back to string for comparison.
|
||
let rendered: String = out
|
||
.iter()
|
||
.flat_map(|l| l.spans.iter())
|
||
.map(|s| s.content.clone())
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
assert_eq!(markdown, rendered);
|
||
// Ensure helper rewrites.
|
||
assert_ne!(markdown, unchanged);
|
||
}
|
||
}
|