use std::path::Path;
use std::sync::Arc;
use hjkl_bonsai::{DotFallbackTheme, Theme};
use hjkl_engine::Query;
use hjkl_syntax_tui::render_output_to_tui;
use hjkl_lang::LanguageDirectory;
pub use hjkl_syntax::{
BufferId, LoadEvent, LoadEventKind, ParseKind, ParseKindKind, PerfBreakdown, SetLanguageOutcome,
};
#[derive(Debug, Clone)]
pub struct RenderOutput {
pub buffer_id: BufferId,
pub spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
pub signs: Vec<hjkl_buffer::Sign>,
pub key: (u64, usize, usize),
#[allow(dead_code)]
pub perf: PerfBreakdown,
pub kind: ParseKind,
}
impl PartialEq for RenderOutput {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.spans == other.spans
&& self.signs.len() == other.signs.len()
&& self
.signs
.iter()
.zip(other.signs.iter())
.all(|(a, b)| a.row == b.row && a.ch == b.ch && a.priority == b.priority)
}
}
fn convert_output(raw: hjkl_syntax::RenderOutput) -> RenderOutput {
let (spans, signs) = render_output_to_tui(&raw);
RenderOutput {
buffer_id: raw.buffer_id,
spans,
signs,
key: raw.key,
perf: raw.perf,
kind: raw.kind,
}
}
pub struct SyntaxLayer {
inner: hjkl_syntax::SyntaxLayer,
pub last_perf: PerfBreakdown,
}
impl SyntaxLayer {
pub fn new(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
Self {
inner: hjkl_syntax::SyntaxLayer::new(theme, directory),
last_perf: PerfBreakdown::default(),
}
}
pub fn set_language_for_path(&mut self, id: BufferId, path: &Path) -> SetLanguageOutcome {
self.inner.set_language_for_path(id, path)
}
pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
self.inner.poll_pending_loads()
}
pub fn forget(&mut self, id: BufferId) {
self.inner.forget(id);
}
pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
self.inner.set_theme(theme);
}
pub fn preview_render(
&self,
id: BufferId,
buffer: &impl Query,
viewport_top: usize,
viewport_height: usize,
) -> Option<RenderOutput> {
let raw = self
.inner
.preview_render(id, buffer, viewport_top, viewport_height)?;
Some(convert_output(raw))
}
pub fn reset(&mut self, id: BufferId) {
self.inner.reset(id);
}
pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
self.inner.apply_edits(id, edits);
}
pub fn submit_render(
&mut self,
id: BufferId,
buffer: &impl Query,
viewport_top: usize,
viewport_height: usize,
kind: ParseKind,
) -> Option<u128> {
self.inner
.submit_render(id, buffer, viewport_top, viewport_height, kind)
}
#[allow(dead_code)]
pub fn take_result(&mut self) -> Option<RenderOutput> {
let raw = self.inner.take_result()?;
self.last_perf = raw.perf;
Some(convert_output(raw))
}
pub fn take_all_results(&mut self) -> Vec<RenderOutput> {
let raws = self.inner.take_all_results();
if let Some(last) = raws.last() {
self.last_perf = last.perf;
}
raws.into_iter().map(convert_output).collect()
}
pub fn wait_result(&mut self, timeout: std::time::Duration) -> Option<RenderOutput> {
let raw = self.inner.wait_result(timeout)?;
self.last_perf = raw.perf;
Some(convert_output(raw))
}
pub fn wait_all_results(&mut self, timeout: std::time::Duration) -> Vec<RenderOutput> {
let raws = self.inner.wait_all_results(timeout);
if let Some(last) = raws.last() {
self.last_perf = last.perf;
}
raws.into_iter().map(convert_output).collect()
}
pub fn wait_for_initial_result(
&mut self,
timeout: std::time::Duration,
) -> Option<RenderOutput> {
self.wait_result(timeout)
}
#[cfg(test)]
pub fn wait_for_result(&mut self, timeout: std::time::Duration) -> Option<RenderOutput> {
self.wait_for_initial_result(timeout)
}
}
pub fn layer_with_theme(
theme: Arc<DotFallbackTheme>,
directory: Arc<LanguageDirectory>,
) -> SyntaxLayer {
SyntaxLayer::new(theme, directory)
}
#[cfg(test)]
pub fn default_layer() -> SyntaxLayer {
let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
}
#[cfg(test)]
mod tests {
use super::*;
use hjkl_buffer::Buffer;
use std::path::Path;
use std::time::Duration;
fn submit_and_wait(
layer: &mut SyntaxLayer,
buf: &Buffer,
top: usize,
height: usize,
) -> Option<RenderOutput> {
layer.submit_render(TID, buf, top, height, ParseKind::Viewport)?;
layer.wait_for_result(Duration::from_secs(5))
}
const TID: BufferId = 0;
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn parse_and_render_small_rust_buffer() {
let buf = Buffer::from_str("fn main() { let x = 1; }\n");
let mut layer = default_layer();
assert!(
layer
.set_language_for_path(TID, Path::new("a.rs"))
.is_known()
);
let out = submit_and_wait(&mut layer, &buf, 0, 10).expect("worker output");
assert_eq!(out.spans.len(), buf.row_count());
assert!(
out.spans.iter().any(|r| !r.is_empty()),
"expected at least one styled span"
);
}
#[test]
fn submit_with_no_language_returns_none() {
let buf = Buffer::from_str("hello world");
let mut layer = default_layer();
assert!(
!layer
.set_language_for_path(TID, Path::new("a.unknownext"))
.is_known()
);
assert!(
layer
.submit_render(TID, &buf, 0, 10, ParseKind::Viewport)
.is_none()
);
}
#[test]
fn apply_edits_with_no_language_is_noop() {
let mut layer = default_layer();
let edits = vec![hjkl_engine::ContentEdit {
start_byte: 0,
old_end_byte: 0,
new_end_byte: 1,
start_position: (0, 0),
old_end_position: (0, 0),
new_end_position: (0, 1),
}];
layer.apply_edits(TID, &edits);
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn first_load_highlights_entire_viewport() {
let mut content = String::new();
for i in 0..50 {
content.push_str(&format!("fn f{i}() {{ let x = {i}; }}\n"));
}
let buf = Buffer::from_str(content.strip_suffix('\n').unwrap_or(&content));
let mut layer = default_layer();
assert!(
layer
.set_language_for_path(TID, Path::new("a.rs"))
.is_known()
);
let out = submit_and_wait(&mut layer, &buf, 0, 30).unwrap();
for (r, row) in out.spans.iter().take(30).enumerate() {
assert!(
!row.is_empty(),
"row {r} has no highlight spans on first load (content: {:?})",
buf.line(r)
);
}
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn first_load_full_viewport_matches_full_parse() {
let mut content = String::new();
for i in 0..50 {
content.push_str(&format!("fn f{i}() {{ let x = {i}; }}\n"));
}
let buf = Buffer::from_str(content.strip_suffix('\n').unwrap_or(&content));
let mut narrow = default_layer();
narrow.set_language_for_path(TID, Path::new("a.rs"));
let narrow_out = submit_and_wait(&mut narrow, &buf, 0, 30).unwrap();
let mut full = default_layer();
full.set_language_for_path(TID, Path::new("a.rs"));
let full_out = submit_and_wait(&mut full, &buf, 0, 100).unwrap();
for r in 0..30 {
assert_eq!(
narrow_out.spans[r], full_out.spans[r],
"row {r} differs between viewport-scoped and full parse"
);
}
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn diagnostics_emit_sign_for_syntax_error() {
let buf = Buffer::from_str("fn main() {\nlet x = ;\n}\n");
let mut layer = default_layer();
layer.set_language_for_path(TID, Path::new("a.rs"));
let out = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
assert!(
!out.signs.is_empty(),
"expected at least one diagnostic sign for `let x = ;`"
);
assert!(
out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
"expected an 'E' sign on row 1; got {:?}",
out.signs
);
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn diagnostics_clean_source_no_signs() {
let buf = Buffer::from_str("fn main() { let x = 1; }\n");
let mut layer = default_layer();
layer.set_language_for_path(TID, Path::new("a.rs"));
let out = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
assert!(
out.signs.is_empty(),
"expected no signs; got {:?}",
out.signs
);
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn incremental_path_matches_cold_for_small_edit() {
let pre = Buffer::from_str("fn main() { let x = 1; }");
let mut layer = default_layer();
layer.set_language_for_path(TID, Path::new("a.rs"));
let _ = submit_and_wait(&mut layer, &pre, 0, 10).unwrap();
layer.apply_edits(
TID,
&[hjkl_engine::ContentEdit {
start_byte: 3,
old_end_byte: 3,
new_end_byte: 4,
start_position: (0, 3),
old_end_position: (0, 3),
new_end_position: (0, 4),
}],
);
let post = Buffer::from_str("fn Ymain() { let x = 1; }");
let inc = submit_and_wait(&mut layer, &post, 0, 10).unwrap();
let mut cold_layer = default_layer();
cold_layer.set_language_for_path(TID, Path::new("a.rs"));
let cold = submit_and_wait(&mut cold_layer, &post, 0, 10).unwrap();
assert_eq!(inc.spans, cold.spans);
}
#[test]
fn worker_handles_quit_cleanly() {
let layer = default_layer();
drop(layer);
}
#[test]
fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
let mut layer = default_layer();
let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
assert!(
!outcome.is_known(),
"expected Unknown for unrecognized extension"
);
assert!(matches!(outcome, SetLanguageOutcome::Unknown));
}
#[test]
fn poll_pending_loads_drains_ready_handles() {
let mut layer = default_layer();
let events = layer.poll_pending_loads();
assert!(
events.is_empty(),
"expected no events with no pending loads"
);
}
#[test]
#[ignore = "disk-state dependent: result depends on whether rust grammar is pre-installed"]
fn set_language_for_path_returns_loading_for_uncached_grammar() {
let mut layer = default_layer();
let t0 = std::time::Instant::now();
let outcome = layer.set_language_for_path(TID, Path::new("a.rs"));
let elapsed = t0.elapsed();
assert!(
elapsed.as_millis() < 500,
"set_language_for_path blocked for {}ms — must be non-blocking",
elapsed.as_millis()
);
assert!(
matches!(outcome, SetLanguageOutcome::Loading(_)),
"expected Loading on cold disk"
);
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn reset_pending_request_is_consumed_once() {
let buf = Buffer::from_str("fn main() {}");
let mut layer = default_layer();
layer.set_language_for_path(TID, Path::new("a.rs"));
layer.reset(TID);
assert!(layer.inner.client_pending_reset(TID));
let _ = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
assert!(
!layer.inner.client_pending_reset(TID),
"pending_reset should clear after submit"
);
}
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn forget_drops_buffer_state() {
let buf = Buffer::from_str("fn main() {}");
let mut layer = default_layer();
layer.set_language_for_path(TID, Path::new("a.rs"));
let _ = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
assert!(layer.inner.has_client(TID));
layer.forget(TID);
assert!(!layer.inner.has_client(TID));
}
}
#[cfg(test)]
mod perf_smoke {
use super::*;
use hjkl_buffer::Buffer;
use std::path::Path;
use std::time::{Duration, Instant};
#[test]
#[ignore = "network + compiler: needs tree-sitter-rust grammar"]
fn big_rs_smoke() {
let path = Path::new("/tmp/big.rs");
if !path.exists() {
eprintln!("/tmp/big.rs not present; skipping perf smoke");
return;
}
let content = std::fs::read_to_string(path).unwrap();
let buf = Buffer::from_str(content.strip_suffix('\n').unwrap_or(&content));
let mut layer = default_layer();
const TID: BufferId = 0;
assert!(layer.set_language_for_path(TID, path).is_known());
let t0 = Instant::now();
layer.submit_render(TID, &buf, 0, 50, ParseKind::Viewport);
let main_t = t0.elapsed();
let out = layer.wait_for_result(Duration::from_secs(10));
eprintln!(
"first submit_render main-thread: {:?}, worker turnaround total: {:?}",
main_t,
t0.elapsed()
);
assert!(out.is_some(), "first parse should produce output");
let t0 = Instant::now();
let mut main_total = Duration::ZERO;
for top in 0..100 {
let s = Instant::now();
layer.submit_render(TID, &buf, top * 100, 50, ParseKind::Viewport);
main_total += s.elapsed();
}
while layer.take_result().is_some() {}
eprintln!(
"100 viewport scrolls: total wall {:?}, main-thread total {:?} (avg {:?}/submit)",
t0.elapsed(),
main_total,
main_total / 100
);
let lines = buf.lines().to_vec();
let mut new_lines = lines.clone();
new_lines[50_000].insert(0, 'X');
let post = Buffer::from_str(&new_lines.join("\n"));
let edit_byte = (0..50_000).map(|r| lines[r].len() + 1).sum::<usize>();
layer.apply_edits(
TID,
&[hjkl_engine::ContentEdit {
start_byte: edit_byte,
old_end_byte: edit_byte,
new_end_byte: edit_byte + 1,
start_position: (50_000, 0),
old_end_position: (50_000, 0),
new_end_position: (50_000, 1),
}],
);
let t = Instant::now();
layer.submit_render(TID, &post, 0, 50, ParseKind::Viewport);
let main_us = t.elapsed();
let out = layer.wait_for_result(Duration::from_secs(10));
eprintln!(
"post-edit submit: main-thread {:?}, worker total {:?} (per-step: {:?})",
main_us,
t.elapsed(),
out.as_ref().map(|o| o.perf),
);
}
}