diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000..4ce18ac --- /dev/null +++ b/CHANGELOG.adoc @@ -0,0 +1,7 @@ += Changelog + +== [Unreleased] + +=== Added + +- initial implementation diff --git a/Cargo.lock b/Cargo.lock index b7b3ade..5f82a3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.2.6" @@ -357,6 +366,9 @@ dependencies = [ "tracing", "tracing-error", "tracing-subscriber", + "tree-sitter", + "tree-sitter-highlight", + "tree-sitter-rust", ] [[package]] @@ -486,6 +498,8 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] @@ -621,6 +635,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.14", +] + [[package]] name = "thread_local" version = "1.1.7" @@ -737,6 +771,37 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "042342584c5a7a0b833d9fc4e2bdab3f9868ddc6c4b339a1e01451c6720868bc" +dependencies = [ + "regex", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797842733e252dc11ae5d403a18060bf337b822fc2ae5ddfaa6ff4d9cc20bda6" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "unicase" version = "2.6.0" diff --git a/Cargo.toml b/Cargo.toml index fdf3696..109d12e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,6 @@ toml = "0.7.3" tracing = "0.1.37" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +tree-sitter = "0.20.10" +tree-sitter-highlight = "0.20.1" +tree-sitter-rust = "0.20.3" diff --git a/rc/highlight.kak b/rc/highlight.kak new file mode 100644 index 0000000..590814a --- /dev/null +++ b/rc/highlight.kak @@ -0,0 +1,54 @@ +declare-option str kak_highlight_cmd "kak-highlight" +set-option global kak_highlight_cmd "kak-highlight -vv" + +declare-option str kak_highlight_log "/tmp/kak-highlight.log" +declare-option -hidden range-specs kak_highlight_ranges + +# Option to store draft of the current buffer before passing to shell. +declare-option -hidden str kak_highlight_draft + +define-command kak-highlight -docstring %{ + kak-highlight + Request highlighting for the current buffer +} %{ + evaluate-commands -draft -no-hooks %{exec '%'; set buffer kak_highlight_draft %val{selection}} + evaluate-commands %sh{ +kak_highlight_draft=$(printf '%s.' "${kak_opt_kak_highlight_draft}" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed "s/$(printf '\t')/\\\\t/g") +kak_highlight_draft=${kak_highlight_draft%.} + +printf ' +timestamp = %d +session = "%s" +client = "%s" +content = """ +%s""" +' "${kak_timestamp}" "${kak_session}" "${kak_client}" "${kak_highlight_draft}" | ${kak_opt_kak_highlight_cmd} request + } +} + +define-command kak-highlight-enable -docstring %{ + kak-highlight-enable + Start a daemon for the current session +} %{ + nop %sh{ + (eval "${kak_opt_kak_highlight_cmd} --log '${kak_opt_kak_highlight_log}' daemon '${kak_session}'") >/dev/null 2>&1 Result<()> { +pub fn handle(runtime_dir: PathBuf) -> Result<()> { let mut buf = String::new(); stdin() .read_to_string(&mut buf) @@ -17,7 +17,7 @@ pub fn handle(runtime_dir: PathBuf, session: String) -> Result<()> { tracing::trace!("Received request: {:?}", req); - let path = runtime_dir.join(session).with_extension("s"); + let path = runtime_dir.join(req.session).with_extension("s"); tracing::debug!(path = %path.display()); let mut socket = UnixStream::connect(&path) @@ -27,7 +27,7 @@ pub fn handle(runtime_dir: PathBuf, session: String) -> Result<()> { .write_all(buf.as_bytes()) .wrap_err("Failed to send request")?; - tracing::info!("Sent request to {}", path.display()); + tracing::debug!("Sent request to {}", path.display()); Ok(()) } diff --git a/src/config.rs b/src/config.rs index bc42e94..0b16de3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io::ErrorKind; use std::path::PathBuf; use std::{env, fs}; @@ -6,10 +7,13 @@ use color_eyre::eyre::Context; use color_eyre::Result; use serde::Deserialize; -#[derive(Copy, Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct Config { /// The number of worker threads to spawn + #[serde(default = "Config::default_workers")] pub workers: u8, + /// A collection mapping highlighter tokens to Kakoune faces + pub tokens: HashMap, } impl Config { @@ -35,11 +39,18 @@ impl Config { let buf = String::from_utf8_lossy(&buf); toml::from_str(&buf).wrap_err("Failed to deserialize config") } + + fn default_workers() -> u8 { + 2 + } } impl Default for Config { fn default() -> Self { - Self { workers: 2 } + Self { + workers: Self::default_workers(), + tokens: Default::default(), + } } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index ead177d..57eadd9 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -7,7 +7,7 @@ use color_eyre::eyre::Context; use color_eyre::Result; use crossbeam::channel::bounded; use crossbeam::select; -use signal_hook::consts::{SIGINT, TERM_SIGNALS}; +use signal_hook::consts::TERM_SIGNALS; use signal_hook::flag; use signal_hook::iterator::Signals; @@ -34,7 +34,7 @@ pub fn handle(runtime_dir: PathBuf, config_path: Option, session: Strin tracing::debug!(?config); let rx_signals = { - let mut signals = Signals::new([SIGINT])?; + let mut signals = Signals::new(TERM_SIGNALS)?; let (tx, rx) = bounded(1); thread::Builder::new() @@ -51,8 +51,8 @@ pub fn handle(runtime_dir: PathBuf, config_path: Option, session: Strin rx }; - let mut scheduler = - TaskScheduler::new(config.workers).wrap_err("Failed to create task scheduler")?; + let mut scheduler = TaskScheduler::new(config.workers, config.tokens) + .wrap_err("Failed to create task scheduler")?; let listener = Listener::new(runtime_dir, session).wrap_err("Failed to create listener")?; loop { diff --git a/src/daemon/worker.rs b/src/daemon/worker.rs index 6eff457..fb9c0c3 100644 --- a/src/daemon/worker.rs +++ b/src/daemon/worker.rs @@ -1,16 +1,19 @@ -use std::io::Read; +use std::collections::{HashMap, VecDeque}; +use std::io::{Read, Write}; +use std::iter; use std::os::unix::net::UnixStream; +use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use std::{fs, iter}; use color_eyre::eyre::{self, Context}; use color_eyre::Result; use crossbeam::deque::{Injector, Stealer, Worker}; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; -use crate::kakoune::editor_quote; +use crate::kakoune::{self, editor_quote}; use crate::Request; type Task = Result; @@ -21,8 +24,18 @@ pub struct TaskScheduler { threads: Vec>, } +pub struct TaskContext { + worker: Worker, + injector: Arc>, + stealers: Arc>>, + terminate: Arc, + highlighter: Highlighter, + highlight_config: Arc, + tokens: Arc>, +} + impl TaskScheduler { - pub fn new(workers: u8) -> Result { + pub fn new(workers: u8, tokens: HashMap) -> Result { let terminate = Arc::new(AtomicBool::new(false)); let injector = Arc::new(Injector::new()); @@ -30,17 +43,38 @@ impl TaskScheduler { let stealers: Vec<_> = workers.iter().map(|w| w.stealer()).collect(); let stealers = Arc::new(stealers); + let mut highlight_config = HighlightConfiguration::new( + tree_sitter_rust::language(), + tree_sitter_rust::HIGHLIGHT_QUERY, + "", + "", + ) + .wrap_err("Invalid highlighter config")?; + + let names: Vec<_> = tokens.keys().collect(); + tracing::debug!("Highlighter tokens: {:?}", names); + highlight_config.configure(&names); + + let highlight_config = Arc::new(highlight_config); + let tokens = Arc::new(tokens); + let threads = workers .into_iter() .enumerate() .map(|(i, worker)| { - let injector = injector.clone(); - let stealers = stealers.clone(); - let terminate = terminate.clone(); + let ctx = TaskContext { + worker, + injector: injector.clone(), + stealers: stealers.clone(), + terminate: terminate.clone(), + highlighter: Highlighter::new(), + highlight_config: highlight_config.clone(), + tokens: tokens.clone(), + }; thread::Builder::new() .name(format!("worker-{}", i)) - .spawn(|| thread_handler(worker, injector, stealers, terminate)) + .spawn(|| thread_handler(ctx)) .map_err(From::from) }) .collect::>>() @@ -83,34 +117,29 @@ fn find_task(local: &Worker, global: &Injector, stealers: &[Stealer] }) } -#[tracing::instrument] -fn thread_handler( - worker: Worker, - injector: Arc>, - stealers: Arc>>, - terminate: Arc, -) { +#[tracing::instrument(skip_all)] +fn thread_handler(mut ctx: TaskContext) { loop { let task = 'find_task: loop { - if terminate.load(Ordering::Relaxed) { + if ctx.terminate.load(Ordering::Relaxed) { return; } - if let Some(task) = find_task(&worker, &injector, &stealers) { + if let Some(task) = find_task(&ctx.worker, &ctx.injector, &ctx.stealers) { break 'find_task task; } thread::sleep(Duration::from_millis(50)); }; - if let Err(err) = handle_connection(task) { + if let Err(err) = handle_connection(&mut ctx, task) { tracing::error!("{:?}", err); } } } #[tracing::instrument(skip_all)] -fn handle_connection(task: Task) -> Result<()> { +fn handle_connection(ctx: &mut TaskContext, task: Task) -> Result<()> { let mut stream = task.wrap_err("Failed to receive client connection")?; let mut buf = String::new(); @@ -119,19 +148,99 @@ fn handle_connection(task: Task) -> Result<()> { .wrap_err("Failed to read from connection")?; let req: Request = toml::from_str(&buf).wrap_err("Failed to parse request")?; - tracing::info!("Received request: {:?}", req); + tracing::info!("Received request"); + tracing::debug!(?req); - let response = process_request(&req) + let response = process_request(ctx, &req) .unwrap_or_else(|err| format!("fail {}", editor_quote(format!("{}", err)))); - tracing::debug!("Sending response:\n{}", response); + let mut child = Command::new("kak") + .args(["-p", &req.session]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .wrap_err("Failed to spawn Kakoune command")?; - fs::write(&req.fifo, response.as_bytes()).wrap_err("Failed to write to command fifo")?; + if let Some(stdin) = child.stdin.as_mut() { + let command = format!( + "evaluate-commands -client {} -verbatim -- {}", + req.client, response + ); + + tracing::info!("Writing response"); + tracing::debug!(command); + + stdin + .write_all(command.as_bytes()) + .wrap_err("Failed to write to Kakoune stdin")?; + } else { + eyre::bail!("Failed to get stdin for Kakoune command"); + } Ok(()) } -#[tracing::instrument] -fn process_request(req: &Request) -> Result { - eyre::bail!("Not implemented") +#[tracing::instrument(skip(ctx, req), fields( + session = req.session, + client = req.client, + content_len = req.content.len(), + timestamp = req.timestamp, +))] +fn process_request(ctx: &mut TaskContext, req: &Request) -> Result { + let names: Vec<_> = ctx.tokens.keys().collect(); + + let highlights = ctx + .highlighter + .highlight(&ctx.highlight_config, req.content.as_bytes(), None, |_| { + None + }) + .wrap_err("Failed to highlight content")?; + + let mut stack = VecDeque::new(); + let mut range_spec = String::new(); + + for res in highlights { + match res? { + HighlightEvent::Source { start, end } => { + if let Some(index) = stack.back() { + // Tree-sitter actually returns the byte position after the token + // as `end` here. + let end = end.saturating_sub(1); + + let range = + kakoune::range_from_byte_offsets(req.content.as_bytes(), start, end); + + tracing::trace!(start, end, ?range, index); + + let spec = format!( + "{}.{},{}.{}|{}", + range.start_point.row, + range.start_point.column, + range.end_point.row, + range.end_point.column, + ctx.tokens[names[*index]] + ); + + range_spec.push(' '); + range_spec.push_str(&spec); + } + } + HighlightEvent::HighlightStart(index) => { + stack.push_back(index.0); + } + HighlightEvent::HighlightEnd => { + // Tree-sitter shouldn't call this when there is nothing on the stack, + // but it wouldn't matter anyways. + let _ = stack.pop_back(); + } + } + } + + let response = format!( + "set-option buffer kak_highlight_ranges {}{range_spec}", + req.timestamp + ); + + Ok(response) } diff --git a/src/kakoune.rs b/src/kakoune.rs index 1b8dae2..6c2177c 100644 --- a/src/kakoune.rs +++ b/src/kakoune.rs @@ -1,4 +1,47 @@ +use tree_sitter::{Point, Range}; + pub fn editor_quote(s: impl AsRef) -> String { // TODO format!("'{}'", s.as_ref()) } + +pub fn range_from_byte_offsets(content: impl AsRef<[u8]>, start: usize, end: usize) -> Range { + // Kakoune's line indices are 1-based + let mut start_row = 1; + let mut start_column = 1; + let mut end_row = 1; + let mut end_column = 1; + + for (i, byte) in content.as_ref().iter().enumerate() { + if i < start { + if *byte == b'\n' { + start_row += 1; + start_column = 1; + } else { + start_column += 1; + } + } + + if i < end { + if *byte == b'\n' { + end_row += 1; + end_column = 1; + } else { + end_column += 1; + } + } + } + + Range { + start_byte: start, + end_byte: end, + start_point: Point { + row: start_row, + column: start_column, + }, + end_point: Point { + row: end_row, + column: end_column, + }, + } +} diff --git a/src/main.rs b/src/main.rs index 5d5cd06..dd50bc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,10 +36,7 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Send a request payload to a running daemon and wait for the response - Request { - /// The Kakoune session this daemon belongs to - session: String, - }, + Request, /// Start a daemon Daemon { /// The Kakoune session this daemon belongs to @@ -49,8 +46,14 @@ enum Command { #[derive(Clone, Debug, Deserialize)] struct Request { - /// The command FIFO provided by Kakoune - fifo: String, + /// The buffer content + content: String, + /// The Kakoune timestamp + timestamp: u64, + /// The Kakoune session + session: String, + /// The Kakoune client + client: String, } #[tracing::instrument] @@ -65,25 +68,20 @@ fn main() -> Result<()> { _ => LevelFilter::TRACE, }; - let stderr_layer = if cfg!(debug_assertions) { + let output_layer = if let Some(path) = cli.log { + let f = File::create(&path) + .wrap_err_with(|| format!("Failed to create log file '{}'", path.display()))?; + fmt::layer().compact().with_writer(f).boxed() + } else if cfg!(debug_assertions) { fmt::layer().pretty().with_writer(stderr).boxed() } else { fmt::layer().compact().with_writer(stderr).boxed() }; - let file_layer = if let Some(path) = cli.log { - let f = File::create(&path) - .wrap_err_with(|| format!("Failed to create log file '{}'", path.display()))?; - let layer = fmt::layer().pretty().with_writer(f); - Some(layer) - } else { - None - }; tracing_subscriber::registry() .with(filter_layer) - .with(stderr_layer) - .with(file_layer) .with(ErrorLayer::new(fmt::format::Pretty::default())) + .with(output_layer) .init(); } @@ -94,6 +92,6 @@ fn main() -> Result<()> { match cli.command { Command::Daemon { session } => daemon::handle(runtime_dir, cli.config, session), - Command::Request { session } => client::handle(runtime_dir, session), + Command::Request => client::handle(runtime_dir), } } diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..8b617e1 --- /dev/null +++ b/test.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -e + +FIFO=/tmp/kak-highlight.test.fifo +REQUEST=$(cat) + +mkfifo $FIFO + +cargo run -- request test <