Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Cached syntax highlighting (spec §8).
Matt W2//!
Matt W3//! > Highlight in the worker and cache the rendered HTML in Postgres keyed by
Matt W4//! > blob OID — highlighting the same file on every request is the easiest
Matt W5//! > performance mistake to make here.
Matt W6//!
Matt W7//! **Deviation, deliberate.** Highlighting happens here on a cache miss rather
Matt W8//! than only in the worker. Precomputing in the worker means the *first* view of
Matt W9//! any file renders unhighlighted, which is the view that matters most after a
Matt W10//! push. The property the spec is actually protecting — never highlight the same
Matt W11//! bytes twice — is provided by the cache, which is what this module is. The
Matt W12//! parse itself runs on a blocking thread so a large file cannot stall the
Matt W13//! async runtime.
Matt W14//!
Matt W15//! The cache key is the blob's content address, so it is shared across
Matt W16//! repositories and across revisions: a file that did not change between two
Matt W17//! revisions is highlighted once, ever. Because the key is a pure function of
Matt W18//! the bytes, there is no invalidation problem — different content is a
Matt W19//! different key.
Matt W20
Matt W21use df_render::highlight::{self, Skipped};
Matt W22use df_store::Blob;
Matt W23use sqlx::PgPool;
Matt W24
Matt W25/// Highlighted lines, or the reason there are none.
Matt W26pub struct Rendered {
Matt W27 /// One HTML fragment per line. Empty when `skipped` is set.
Matt W28 pub lines: Vec<String>,
Matt W29 pub language: Option<String>,
Matt W30 /// Set when the file is to be rendered as plain text, with the reason.
Matt W31 pub skipped: Option<Skipped>,
Matt W32}
Matt W33
Matt W34impl Rendered {
Matt W35 fn plain(reason: Skipped) -> Self {
Matt W36 Rendered { lines: Vec::new(), language: None, skipped: Some(reason) }
Matt W37 }
Matt W38}
Matt W39
Matt W40/// Highlight a blob, consulting and populating the Postgres cache.
Matt W41///
Matt W42/// Never fails: a database error degrades to highlighting without the cache,
Matt W43/// and a highlighting failure degrades to plain text. A syntax colour is not
Matt W44/// worth a 500.
Matt W45pub async fn render(db: &PgPool, blob: &Blob) -> Rendered {
Matt W46 let Some(text) = blob.text() else {
Matt W47 return Rendered::plain(Skipped::NoGrammar);
Matt W48 };
Matt W49
Matt W50 // No grammar means nothing to cache — decided from the path alone, so this
Matt W51 // costs one table lookup and avoids a pointless database round trip for
Matt W52 // every LICENSE and .gitignore in the tree.
Matt W53 let Some(lang) = highlight::language_for_path(&blob.path) else {
Matt W54 return Rendered::plain(Skipped::NoGrammar);
Matt W55 };
Matt W56
Matt W57 match load(db, &blob.content_id).await {
Matt W58 Ok(Some(hit)) => return hit,
Matt W59 Ok(None) => {}
Matt W60 Err(e) => tracing::warn!("highlight cache read failed, rendering uncached: {e}"),
Matt W61 }
Matt W62
Matt W63 // tree-sitter is CPU-bound and this is an async handler; parsing a megabyte
Matt W64 // on the runtime thread would stall every other request on that worker.
Matt W65 let path = blob.path.clone();
Matt W66 let source = text.to_owned();
Matt W67 let highlighted =
Matt W68 match tokio::task::spawn_blocking(move || highlight::highlight(&path, &source)).await {
Matt W69 Ok(r) => r,
Matt W70 Err(e) => {
Matt W71 tracing::error!("highlighting panicked: {e}");
Matt W72 return Rendered::plain(Skipped::Failed);
Matt W73 }
Matt W74 };
Matt W75
Matt W76 let out = match highlighted {
Matt W77 Ok(h) => Rendered {
Matt W78 lines: h.lines,
Matt W79 language: Some(h.language.to_string()),
Matt W80 skipped: None,
Matt W81 },
Matt W82 Err(reason) => Rendered::plain(reason),
Matt W83 };
Matt W84
Matt W85 // Only successful renders are cached. Caching a rejection would save
Matt W86 // nothing: every rejection is decided from the path or the size, both of
Matt W87 // which are already known before the lookup.
Matt W88 if out.skipped.is_none() {
Matt W89 if let Err(e) = store(db, &blob.content_id, lang.display, &out.lines).await {
Matt W90 tracing::warn!("highlight cache write failed: {e}");
Matt W91 }
Matt W92 }
Matt W93
Matt W94 out
Matt W95}
Matt W96
Matt W97/// The separator between cached line fragments.
Matt W98///
Matt W99/// A form feed rather than a newline: the HTML fragments themselves are free of
Matt W100/// newlines, but a fragment could in principle contain one from a multi-line
Matt W101/// token, and splitting on the wrong character would silently corrupt the line
Matt W102/// numbering. U+000C cannot appear in the renderer's output.
Matt W103const LINE_SEP: char = '\u{c}';
Matt W104
Matt W105async fn load(db: &PgPool, content_id: &str) -> sqlx::Result<Option<Rendered>> {
Matt W106 let row: Option<(Option<String>, String)> =
Matt W107 sqlx::query_as("SELECT language, html FROM highlight_cache WHERE blob_oid = $1")
Matt W108 .bind(content_id)
Matt W109 .fetch_optional(db)
Matt W110 .await?;
Matt W111
Matt W112 Ok(row.map(|(language, html)| Rendered {
Matt W113 lines: if html.is_empty() {
Matt W114 Vec::new()
Matt W115 } else {
Matt W116 html.split(LINE_SEP).map(str::to_owned).collect()
Matt W117 },
Matt W118 language,
Matt W119 skipped: None,
Matt W120 }))
Matt W121}
Matt W122
Matt W123async fn store(db: &PgPool, content_id: &str, language: &str, lines: &[String]) -> sqlx::Result<()> {
Matt W124 let html = lines.join(&LINE_SEP.to_string());
Matt W125
Matt W126 // ON CONFLICT DO NOTHING: two requests racing on the same blob both compute
Matt W127 // the same bytes, so whichever lands first is correct and the other is a
Matt W128 // no-op rather than a lock wait.
Matt W129 sqlx::query(
Matt W130 "INSERT INTO highlight_cache (blob_oid, language, html, bytes)
Matt W131 VALUES ($1, $2, $3, $4)
Matt W132 ON CONFLICT (blob_oid) DO NOTHING",
Matt W133 )
Matt W134 .bind(content_id)
Matt W135 .bind(language)
Matt W136 .bind(&html)
Matt W137 .bind(html.len() as i32)
Matt W138 .execute(db)
Matt W139 .await?;
Matt W140
Matt W141 Ok(())
Matt W142}

142 lines · Rust