use std::ops::Range;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use gpui_kit::{App, SharedString, Window};
use super::{
bibtex::bibtex_entry_offset,
protocol::{EditorBuffer, RoundtripContext, char_range_to_bytes},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorSnapshot {
pub id: u64,
pub name: SharedString,
pub path: Option<PathBuf>,
pub cursor: (usize, usize),
pub content: SharedString,
}
impl EditorSnapshot {
#[must_use]
pub fn buffer(&self) -> EditorBuffer<'_> {
EditorBuffer {
name: self.name.as_ref(),
path: self.path.as_deref(),
cursor: self.cursor,
content: self.content.as_ref(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectionSnapshot {
pub document: EditorSnapshot,
pub range: Range<usize>,
}
pub trait EditorSurface {
fn id(&self) -> u64;
fn name(&self) -> SharedString;
fn path(&self) -> Option<&Path>;
fn text(&self, cx: &App) -> SharedString;
fn cursor(&self, cx: &App) -> (usize, usize);
fn selection(&self, window: &mut Window, cx: &mut App) -> Option<Range<usize>>;
fn replace(&self, range: Range<usize>, text: &str, window: &mut Window, cx: &mut App);
fn focus(&self, window: &mut Window, cx: &mut App);
fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App);
fn caret(&self, cx: &App) -> usize {
let snapshot = self.snapshot(cx);
char_index_for_line_column(&snapshot.content, snapshot.cursor)
}
fn insert_text(&self, text: &str, window: &mut Window, cx: &mut App) {
if text.is_empty() {
return;
}
let content = self.text(cx);
let range = self.selection(window, cx).unwrap_or_else(|| {
let caret = self.caret(cx);
caret..caret
});
self.replace(char_range_to_bytes(&content, range), text, window, cx);
}
fn snapshot(&self, cx: &App) -> EditorSnapshot {
EditorSnapshot {
id: self.id(),
name: self.name(),
path: self.path().map(Path::to_path_buf),
cursor: self.cursor(cx),
content: self.text(cx),
}
}
}
#[must_use]
pub fn char_index_for_line_column(content: &str, cursor: (usize, usize)) -> usize {
let mut line = 0usize;
let mut column = 0usize;
for (byte, character) in content.char_indices() {
if line == cursor.0 && column == cursor.1 {
return content[..byte].chars().count();
}
if character == '\n' {
line += 1;
column = 0;
} else {
column += 1;
}
}
content.chars().count()
}
pub trait RoundtripHost {
fn active_document(&self, cx: &App) -> Option<EditorSnapshot>;
fn selection(&self, window: &mut Window, cx: &mut App) -> Option<SelectionSnapshot>;
fn insert(&self, _text: &str, _window: &mut Window, _cx: &mut App) -> bool {
false
}
fn bibliography(&self, _cx: &App) -> Option<PathBuf> {
None
}
fn open_at(&self, _path: &Path, _offset: usize, _window: &mut Window, _cx: &mut App) -> bool {
false
}
fn edit_reference(&self, key: &str, window: &mut Window, cx: &mut App) -> Result<(), String> {
let Some(path) = self.bibliography(cx) else {
return Err("No bibliography configured".to_owned());
};
let text = std::fs::read_to_string(&path)
.map_err(|error| format!("cannot read '{}' for @{key}: {error}", path.display()))?;
let Some(offset) = bibtex_entry_offset(&text, key) else {
return Err(format!("Entry @{key} is not in {}", path.display()));
};
if self.open_at(&path, offset, window, cx) {
Ok(())
} else {
Err(format!("'{}' could not be opened", path.display()))
}
}
fn apply_replacement(
&self,
target: ReplacementTarget,
text: &str,
window: &mut Window,
cx: &mut App,
) -> ReplacementOutcome;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReplacementTarget {
pub document_id: u64,
pub range: Range<usize>,
pub digest: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReplacementOutcome {
Applied {
message: String,
},
Offered {
text: String,
reason: String,
},
Empty,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectionContext {
pub document_id: u64,
pub context: RoundtripContext,
}
#[derive(Clone)]
pub struct Roundtrip {
host: Rc<dyn RoundtripHost>,
}
impl Roundtrip {
pub fn new(host: Rc<dyn RoundtripHost>) -> Self {
Self { host }
}
#[must_use]
pub fn active_document(&self, cx: &App) -> Option<EditorSnapshot> {
self.host.active_document(cx)
}
#[must_use]
pub fn document_context(&self, cx: &App, limit: usize) -> Option<RoundtripContext> {
let document = self.host.active_document(cx)?;
RoundtripContext::document_with_limit(document.buffer(), limit)
}
#[must_use]
pub fn selection_context(
&self,
window: &mut Window,
cx: &mut App,
limit: usize,
) -> Option<SelectionContext> {
let selection = self.host.selection(window, cx)?;
let context = RoundtripContext::selection_with_limit(
selection.document.buffer(),
selection.range,
limit,
)?;
Some(SelectionContext {
document_id: selection.document.id,
context,
})
}
pub fn apply(
&self,
target: ReplacementTarget,
text: &str,
window: &mut Window,
cx: &mut App,
) -> ReplacementOutcome {
self.host.apply_replacement(target, text, window, cx)
}
pub fn insert(&self, text: &str, window: &mut Window, cx: &mut App) -> bool {
self.host.insert(text, window, cx)
}
pub fn edit_reference(
&self,
key: &str,
window: &mut Window,
cx: &mut App,
) -> Result<(), String> {
self.host.edit_reference(key, window, cx)
}
pub fn open_at(&self, path: &Path, offset: usize, window: &mut Window, cx: &mut App) -> bool {
self.host.open_at(path, offset, window, cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockEditor {
content: RefCell<String>,
caret: RefCell<usize>,
selection: RefCell<Option<Range<usize>>>,
}
impl MockEditor {
fn new(content: &str, caret: usize) -> Self {
Self {
content: RefCell::new(content.to_owned()),
caret: RefCell::new(caret),
selection: RefCell::new(None),
}
}
fn selected(mut self, range: Range<usize>) -> Self {
self.selection = RefCell::new(Some(range));
self
}
fn value(&self) -> String {
self.content.borrow().clone()
}
}
impl EditorSurface for MockEditor {
fn id(&self) -> u64 {
1
}
fn name(&self) -> SharedString {
"mock.md".into()
}
fn path(&self) -> Option<&Path> {
None
}
fn text(&self, _: &App) -> SharedString {
self.content.borrow().clone().into()
}
fn cursor(&self, _: &App) -> (usize, usize) {
let content = self.content.borrow();
let caret = (*self.caret.borrow()).min(content.chars().count());
let before: String = content.chars().take(caret).collect();
(
before.matches('\n').count(),
before.rsplit('\n').next().unwrap_or("").chars().count(),
)
}
fn selection(&self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
self.selection.borrow().clone()
}
fn replace(&self, range: Range<usize>, text: &str, _: &mut Window, _: &mut App) {
let content = self.content.borrow().clone();
let mut next = content[..range.start].to_owned();
next.push_str(text);
next.push_str(&content[range.end..]);
*self.caret.borrow_mut() =
content[..range.start].chars().count() + text.chars().count();
*self.content.borrow_mut() = next;
*self.selection.borrow_mut() = None;
}
fn focus(&self, _: &mut Window, _: &mut App) {}
fn reveal(&self, offset: usize, window: &mut Window, cx: &mut App) {
*self.caret.borrow_mut() = offset;
*self.selection.borrow_mut() = None;
self.focus(window, cx);
}
}
struct MockHost {
bibliography: Option<PathBuf>,
opened: RefCell<Vec<(PathBuf, usize)>>,
can_open: bool,
}
impl MockHost {
fn with_bibliography(path: PathBuf) -> Self {
Self {
bibliography: Some(path),
opened: RefCell::new(Vec::new()),
can_open: true,
}
}
}
impl RoundtripHost for MockHost {
fn active_document(&self, _: &App) -> Option<EditorSnapshot> {
None
}
fn selection(&self, _: &mut Window, _: &mut App) -> Option<SelectionSnapshot> {
None
}
fn bibliography(&self, _: &App) -> Option<PathBuf> {
self.bibliography.clone()
}
fn open_at(&self, path: &Path, offset: usize, _: &mut Window, _: &mut App) -> bool {
self.opened.borrow_mut().push((path.to_path_buf(), offset));
self.can_open
}
fn apply_replacement(
&self,
_: ReplacementTarget,
_: &str,
_: &mut Window,
_: &mut App,
) -> ReplacementOutcome {
ReplacementOutcome::Empty
}
}
fn temp_bibliography(label: &str) -> (PathBuf, PathBuf) {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ragrig-gpui-host-{label}-{}-{counter}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("references.bib");
std::fs::write(
&path,
"@article{Kano2025,\n title = {Gaze},\n}\n\n@book{Mila2023,\n title = {Sclera},\n}\n",
)
.expect("bib");
(dir, path)
}
struct Root;
impl gpui_kit::Render for Root {
fn render(
&mut self,
_: &mut Window,
_: &mut gpui_kit::Context<Self>,
) -> impl gpui_kit::IntoElement {
gpui_kit::div()
}
}
#[gpui_kit::test]
fn the_default_insert_lands_at_the_caret(cx: &mut gpui_kit::TestAppContext) {
let (_root, cx) = cx.add_window_view(|_, _| Root);
let editor = MockEditor::new("hello world", 5);
cx.update(|window, cx| editor.insert_text(" brave", window, cx));
assert_eq!(editor.value(), "hello brave world");
}
#[gpui_kit::test]
fn the_default_insert_replaces_the_selection(cx: &mut gpui_kit::TestAppContext) {
let (_root, cx) = cx.add_window_view(|_, _| Root);
let editor = MockEditor::new("hello world", 0).selected(0..5);
cx.update(|window, cx| editor.insert_text("hi", window, cx));
assert_eq!(editor.value(), "hi world");
}
#[gpui_kit::test]
fn the_default_edit_reference_opens_the_entry(cx: &mut gpui_kit::TestAppContext) {
let (dir, path) = temp_bibliography("edit");
let host = MockHost::with_bibliography(path.clone());
let (_root, cx) = cx.add_window_view(|_, _| Root);
let result = cx.update(|window, cx| {
host.edit_reference("Mila2023", window, cx)
.expect("entry is opened");
host.opened.borrow().clone()
});
let text = std::fs::read_to_string(&path).expect("bib");
assert_eq!(
result,
vec![(
path,
bibtex_entry_offset(&text, "Mila2023").expect("offset")
)]
);
let _ = std::fs::remove_dir_all(dir);
}
#[gpui_kit::test]
fn the_default_edit_reference_reports_a_missing_key(cx: &mut gpui_kit::TestAppContext) {
let (dir, path) = temp_bibliography("missing-key");
let host = MockHost::with_bibliography(path);
let (_root, cx) = cx.add_window_view(|_, _| Root);
let error = cx.update(|window, cx| {
host.edit_reference("Nope", window, cx)
.expect_err("missing key")
});
assert!(error.contains("@Nope"), "unexpected error: {error}");
assert!(host.opened.borrow().is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[gpui_kit::test]
fn the_default_edit_reference_requires_a_bibliography(cx: &mut gpui_kit::TestAppContext) {
let host = MockHost {
bibliography: None,
opened: RefCell::new(Vec::new()),
can_open: true,
};
let (_root, cx) = cx.add_window_view(|_, _| Root);
let error = cx.update(|window, cx| {
host.edit_reference("Kano2025", window, cx)
.expect_err("no bibliography")
});
assert_eq!(error, "No bibliography configured");
assert!(host.opened.borrow().is_empty());
}
#[test]
fn line_columns_convert_to_char_indices() {
assert_eq!(char_index_for_line_column("abc", (0, 0)), 0);
assert_eq!(char_index_for_line_column("abc", (0, 3)), 3);
assert_eq!(char_index_for_line_column("ab\ncd", (1, 1)), 4);
assert_eq!(char_index_for_line_column("ab\ncd", (1, 99)), 5);
assert_eq!(char_index_for_line_column("ab\ncd", (9, 0)), 5);
}
}