Files
galaxy/crates/ipynb_parser/src/lib.rs
T

402 lines
14 KiB
Rust

//! Parses `.ipynb` (Jupyter) notebooks directly into the [`FormattedText`]
//! representation that Warp's rich-text/notebook renderer consumes.
//!
//! This is **render-only**: it produces a read-only view of the notebook's
//! existing content (markdown cells, code cells, and saved outputs). It does
//! not execute cells or round-trip edits back to the file.
//!
//! Only nbformat v4 is supported. Anything that fails to parse as a v4 notebook
//! returns an [`IpynbError`] so callers can fall back to showing the raw file
//! contents instead of a blank view.
use std::collections::BTreeMap;
use markdown_parser::{
CodeBlockText, FormattedImage, FormattedText, FormattedTextFragment, FormattedTextLine,
parse_markdown, parse_markdown_with_gfm_tables,
};
use serde::Deserialize;
/// The only nbformat major version this converter understands.
const SUPPORTED_NBFORMAT: i64 = 4;
/// Maximum length of a code-block language tag we will emit. Real language
/// names are short; a longer value is treated as untrusted junk and dropped so
/// it cannot bloat every code block.
const MAX_LANGUAGE_TAG_CHARS: usize = 32;
/// Error produced when the input cannot be rendered as a supported notebook.
#[derive(Debug, thiserror::Error)]
pub enum IpynbError {
/// The input was not valid notebook JSON.
#[error("failed to parse notebook JSON: {0}")]
Parse(#[from] serde_json::Error),
/// The notebook used an unsupported nbformat version (only v4 is supported).
#[error(
"unsupported notebook format: nbformat={nbformat:?} (only v{SUPPORTED_NBFORMAT} is supported)"
)]
UnsupportedFormat { nbformat: Option<i64> },
}
/// Convert the JSON contents of a `.ipynb` file into [`FormattedText`].
///
/// `gfm_tables` selects the GFM-table-aware Markdown parser for markdown cells,
/// mirroring the `Buffer::from_markdown` behavior (the caller passes the
/// `MarkdownTables` feature flag state).
///
/// Returns an [`IpynbError`] if the input is not a parseable nbformat v4
/// notebook; callers should fall back to [`raw_fallback_formatted_text`] in that
/// case so the contents are shown verbatim (never a blank view).
pub fn ipynb_to_formatted_text(json: &str, gfm_tables: bool) -> Result<FormattedText, IpynbError> {
let notebook: Notebook = serde_json::from_str(json)?;
// Guard against arbitrary JSON that happens to deserialize into an empty
// notebook: require an explicit, supported nbformat version.
if notebook.nbformat != Some(SUPPORTED_NBFORMAT) {
return Err(IpynbError::UnsupportedFormat {
nbformat: notebook.nbformat,
});
}
let language = notebook.language();
// Convert each cell independently (a 1:1 map with an exact size hint), then
// size the final buffer from the per-cell line counts so it is allocated
// exactly once instead of growing incrementally.
let per_cell: Vec<Vec<FormattedTextLine>> = notebook
.cells
.iter()
.map(|cell| cell_lines(cell, &language, gfm_tables))
.collect();
let total_lines = per_cell.iter().map(Vec::len).sum();
let mut lines = Vec::with_capacity(total_lines);
for lines_for_cell in per_cell {
lines.extend(lines_for_cell);
}
Ok(FormattedText::new_trimmed(lines))
}
/// Build a verbatim [`FormattedText`] fallback for content that is not a
/// parseable notebook (malformed JSON, unsupported nbformat version, etc.). The
/// raw content is placed in a single code block so any Markdown/HTML inside it
/// is shown verbatim, never re-interpreted.
pub fn raw_fallback_formatted_text(content: &str) -> FormattedText {
FormattedText::new_trimmed(vec![FormattedTextLine::CodeBlock(CodeBlockText {
lang: "json".to_string(),
code: content.trim_end_matches('\n').to_string(),
})])
}
/// Convert a single notebook cell into its formatted-text lines.
fn cell_lines(cell: &Cell, language: &str, gfm_tables: bool) -> Vec<FormattedTextLine> {
match cell.cell_type.as_str() {
"markdown" => {
let source = cell.source.to_text();
markdown_block_lines(source.trim_end_matches('\n'), gfm_tables)
}
"code" => {
let source = cell.source.to_text();
let mut lines = code_block_lines(language, source.trim_end_matches('\n'));
lines.extend(cell.outputs.iter().flat_map(output_lines));
lines
}
// Raw cells are passed through verbatim in Jupyter; render them as a
// plain (unhighlighted) code block so their contents can't inject
// unexpected markdown.
"raw" => {
let source = cell.source.to_text();
code_block_lines("", source.trim_end_matches('\n'))
}
// Unknown / future cell types are skipped rather than rendered raw.
_ => Vec::new(),
}
}
/// A markdown cell, parsed into formatted text and separated from surrounding
/// blocks by a line break. Empty cells produce no lines.
fn markdown_block_lines(content: &str, gfm_tables: bool) -> Vec<FormattedTextLine> {
if content.is_empty() {
return Vec::new();
}
let parse_fn = if gfm_tables {
parse_markdown_with_gfm_tables
} else {
parse_markdown
};
// A markdown cell is, by definition, Markdown; parse it once. If parsing
// somehow fails, preserve the content verbatim as a plain line rather than
// dropping it.
let parsed = parse_fn(content).unwrap_or_else(|_| {
FormattedText::new(vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(content),
])])
});
let mut lines = Vec::from(parsed.lines);
lines.push(FormattedTextLine::LineBreak);
lines
}
/// A code block with the given language tag (empty for none), separated from
/// surrounding blocks by a line break.
fn code_block_lines(language: &str, content: &str) -> Vec<FormattedTextLine> {
vec![
FormattedTextLine::CodeBlock(CodeBlockText {
lang: language.to_string(),
code: content.to_string(),
}),
FormattedTextLine::LineBreak,
]
}
/// The lines for a single saved cell output. Skipped outputs produce no lines.
fn output_lines(output: &Output) -> Vec<FormattedTextLine> {
match output.output_type.as_str() {
"stream" => match &output.text {
Some(text) => text_output_lines(&text.to_text()),
None => Vec::new(),
},
"execute_result" | "display_data" => {
let Some(data) = &output.data else {
return Vec::new();
};
// Prefer images, then plain text. Other MIME types (text/html,
// LaTeX, widgets, ...) are intentionally skipped in v1.
if let Some(value) = data.get("image/png") {
image_lines("image/png", value)
} else if let Some(value) = data.get("image/jpeg") {
image_lines("image/jpeg", value)
} else if let Some(value) = data.get("text/plain") {
text_output_lines(&value_to_text(value))
} else {
Vec::new()
}
}
"error" => {
let traceback = output
.traceback
.as_ref()
.map(|tb| tb.join("\n"))
.unwrap_or_default();
// ANSI escapes (common in colored tracebacks) are stripped centrally
// by `text_output_lines`.
text_output_lines(&traceback)
}
// Unknown output types are skipped.
_ => Vec::new(),
}
}
/// A text output as a plain (unhighlighted) code block. Empty output produces
/// no lines.
///
/// TODO: Bounding pathologically large outputs is left to
/// a future change that models truncation without polluting buffer content
/// (e.g. a "show more" affordance) rather than injecting placeholder text.
fn text_output_lines(text: &str) -> Vec<FormattedTextLine> {
// TODO: Remove ANSI stripping once we support ANSI-color rendering (a color
// attribute on `FormattedTextStyles` + SGR parsing)
let stripped = strip_ansi(text);
let text = stripped.trim_end_matches('\n');
if text.is_empty() {
return Vec::new();
}
code_block_lines("", text)
}
/// An embedded image output as a base64 data-URI image. Empty payloads produce
/// no lines.
fn image_lines(mime: &str, value: &serde_json::Value) -> Vec<FormattedTextLine> {
let base64: String = value_to_text(value)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if base64.is_empty() {
return Vec::new();
}
vec![
FormattedTextLine::Image(FormattedImage {
alt_text: "output".to_string(),
source: format!("data:{mime};base64,{base64}"),
title: None,
}),
FormattedTextLine::LineBreak,
]
}
/// Strip ANSI escape sequences (CSI/SGR colors, OSC, and simple escapes) so
/// tracebacks render as readable plain text.
fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
match chars.peek() {
// CSI sequence: ESC [ ... <final byte 0x40-0x7E>
Some('[') => {
chars.next();
for next in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&next) {
break;
}
}
}
// OSC sequence: ESC ] ... terminated by BEL or ST (ESC \)
Some(']') => {
chars.next();
while let Some(&next) = chars.peek() {
if next == '\u{07}' {
chars.next();
break;
}
if next == '\u{1b}' {
chars.next();
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
chars.next();
}
}
// Other escapes (e.g. ESC ( B): drop the single following byte.
Some(_) => {
chars.next();
}
None => {}
}
}
out
}
/// Convert a JSON value that is either a string or an array of strings into a
/// single string (notebook source and text fields use both forms).
fn value_to_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(items) => items.iter().filter_map(|v| v.as_str()).collect(),
_ => String::new(),
}
}
/// Sanitize a notebook-declared language into a safe code-block language tag.
fn sanitize_language(raw: &str) -> String {
let trimmed = raw.trim();
let is_safe = !trimmed.is_empty()
&& trimmed.chars().count() <= MAX_LANGUAGE_TAG_CHARS
&& trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '#' | '-' | '_' | '.'));
if is_safe {
trimmed.to_string()
} else {
String::new()
}
}
/// Top-level notebook structure (nbformat v4, only the fields we render).
#[derive(Debug, Deserialize)]
struct Notebook {
#[serde(default)]
nbformat: Option<i64>,
cells: Vec<Cell>,
#[serde(default)]
metadata: Metadata,
}
impl Notebook {
/// The code-block language, derived from notebook metadata and sanitized to
/// a safe tag (see [`sanitize_language`]). Empty if the notebook does not
/// declare a language, or declares one that is not a safe identifier.
fn language(&self) -> String {
let raw = self
.metadata
.language_info
.as_ref()
.and_then(|info| info.name.clone())
.or_else(|| {
self.metadata
.kernelspec
.as_ref()
.and_then(|spec| spec.language.clone())
})
.unwrap_or_default();
sanitize_language(&raw)
}
}
#[derive(Debug, Default, Deserialize)]
struct Metadata {
#[serde(default)]
language_info: Option<LanguageInfo>,
#[serde(default)]
kernelspec: Option<Kernelspec>,
}
#[derive(Debug, Deserialize)]
struct LanguageInfo {
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Kernelspec {
#[serde(default)]
language: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Cell {
#[serde(default)]
cell_type: String,
#[serde(default)]
source: Source,
#[serde(default)]
outputs: Vec<Output>,
}
#[derive(Debug, Deserialize)]
struct Output {
#[serde(default)]
output_type: String,
/// Present for `stream` outputs.
#[serde(default)]
text: Option<Source>,
/// Present for `execute_result` / `display_data` outputs (MIME -> value).
#[serde(default)]
data: Option<BTreeMap<String, serde_json::Value>>,
/// Present for `error` outputs.
#[serde(default)]
traceback: Option<Vec<String>>,
}
/// A notebook `source`/`text` field, which may be a single string or a list of
/// strings (each typically including its trailing newline).
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Source {
Lines(Vec<String>),
Text(String),
}
impl Default for Source {
fn default() -> Self {
Source::Text(String::new())
}
}
impl Source {
fn to_text(&self) -> String {
match self {
Source::Lines(lines) => lines.concat(),
Source::Text(text) => text.clone(),
}
}
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;