logoxide-lsp 0.1.0

A language server for Bible references and text: completion, hover, symbols, diagnostics, and passages from logoxide packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Searching the whole workspace: every text file under the workspace folders (respecting
//! `.gitignore`, `.ignore`, and `.toposignore`, and skipping hidden and binary files, like the
//! CLI), with open documents' unsaved text instead of what is on disk, and a cache of each
//! file's references.
//!
//! With the `epub` setting, EPUBs are searched too (and with `pdf`, PDFs, in a build with the
//! `pdf` feature), through
//! topos's cache on disk (the CLI's, so a book is read once whichever searched it first). Their
//! references are in `logoxide-file://` documents: the book's text ([`book_text`]), where each
//! reference is at its line and column.

use std::{
    collections::{HashMap, HashSet},
    path::{Path, PathBuf},
    str::FromStr,
    sync::{
        Arc, Mutex,
        atomic::{AtomicBool, Ordering},
    },
    time::{Instant, SystemTime},
};

use ignore::WalkState;
use lsp_types::{Position, Range, Uri};
use topos_bible::{
    matcher::{BibleMatcher, LineIndex},
    segments::Passage,
};
use topos_bible_index::files::{self, CfiOptions, Hit, WalkOptions};

/// The scheme of a book's text: `logoxide-file:///home/me/Books/Gill.epub`
pub const BOOK_SCHEME: &str = "logoxide-file";

/// Searching books (the `epub` and `pdf` settings), with topos's cache on disk if it has one
pub struct BookSearch {
    pub disk: Option<files::Cache>,
    pub epub: bool,
    /// Only in a build with the `pdf` feature
    pub pdf: bool,
}

impl BookSearch {
    /// Searching EPUBs, PDFs, or both (`None` for neither), with topos's cache in its usual
    /// folder, for this `fingerprint` (see [`files::fingerprint`])
    pub fn new(epub: bool, pdf: bool, fingerprint: &str) -> Option<Self> {
        let pdf = pdf && cfg!(feature = "pdf");
        (epub || pdf).then(|| Self {
            disk: files::Cache::open(fingerprint),
            epub,
            pdf,
        })
    }

    /// Whether it searches this file
    pub fn searches(&self, path: &Path) -> bool {
        let extension = path.extension().and_then(|e| e.to_str());
        let is = |name: &str| extension.is_some_and(|e| e.eq_ignore_ascii_case(name));
        (self.epub && is("epub")) || (self.pdf && is("pdf"))
    }
}

/// Which files a workspace search reads
#[derive(Clone, Copy)]
pub struct Scope<'a> {
    /// Text files with one of these extensions (any if empty)
    pub extensions: &'a [String],
    /// EPUBs or PDFs too, whatever `extensions` says (the `epub` and `pdf` settings)
    pub books: Option<&'a BookSearch>,
}

/// A book's document: `logoxide-file:///home/me/Books/Gill.epub`
pub fn book_uri(path: &Path) -> Option<Uri> {
    let file = path_to_uri(path)?;
    let rest = file.as_str().strip_prefix("file")?;
    Uri::from_str(&format!("{BOOK_SCHEME}{rest}")).ok()
}

/// The path of a book's document
pub fn book_path(uri: &str) -> Option<PathBuf> {
    let rest = uri.strip_prefix(BOOK_SCHEME)?;
    uri_to_path(&Uri::from_str(&format!("file{rest}")).ok()?)
}

/// A book's text, as its references' lines and columns count it
pub fn book_text(path: &Path) -> Result<String, String> {
    let extension = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or_default();
    if extension.eq_ignore_ascii_case("epub") {
        let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
        return topos_bible_formats::epub::epub_text_reader(std::io::BufReader::new(file))
            .map_err(|e| format!("{}: {e}", path.display()));
    }
    #[cfg(feature = "pdf")]
    if extension.eq_ignore_ascii_case("pdf") {
        return topos_bible_formats::pdf::pdf_file_text(path)
            .map_err(|e| format!("{}: {e}", path.display()));
    }
    Err(format!("{} isn't a book (an EPUB or PDF)", path.display()))
}

/// Where a book's reference is in its text, in LSP positions (UTF-16 columns, as topos counts
/// them)
fn book_found(hit: &Hit) -> Option<Found> {
    let (line, column, len) = match (&hit.epub, &hit.pdf) {
        (Some(e), _) => (e.line, e.utf16_column, e.text_utf16.len()),
        (_, Some(p)) => (p.line, p.utf16_column, p.text_utf16.len()),
        _ => return None,
    };
    let start = Position::new(
        line.saturating_sub(1) as u32,
        column.saturating_sub(1) as u32,
    );
    let end = Position::new(start.line, start.character + len as u32);
    Some(Found {
        range: Range::new(start, end),
        passage: hit.passage.clone(),
    })
}

/**
A book's references: from topos's cache on disk while the book is unchanged, else read (and
cached). A book that can't be read has none, and isn't cached, so it's tried again
*/
fn book_references(matcher: &BibleMatcher, disk: Option<&files::Cache>, path: &Path) -> Vec<Found> {
    let hits = match disk.and_then(|cache| cache.get(path, None)) {
        Some(files::Cached::Hits(hits)) => hits,
        _ => match files::search_file(matcher, path, false, CfiOptions::default()) {
            Ok(found) => {
                let hits = found.map(|f| f.hits).unwrap_or_default();
                if let Some(cache) = disk {
                    cache.insert(path, &hits);
                }
                hits
            }
            Err(_) => vec![],
        },
    };
    hits.iter().filter_map(book_found).collect()
}

/// When a workspace search gives up, checked between files: at a deadline (hover's count), or
/// when its request is cancelled (`$/cancelRequest`)
#[derive(Clone, Debug, Default)]
pub struct Stop {
    pub deadline: Option<Instant>,
    pub cancelled: Option<Arc<AtomicBool>>,
}

impl Stop {
    pub fn at(deadline: Instant) -> Self {
        Self {
            deadline: Some(deadline),
            cancelled: None,
        }
    }

    pub fn when(cancelled: Arc<AtomicBool>) -> Self {
        Self {
            deadline: None,
            cancelled: Some(cancelled),
        }
    }

    pub fn reached(&self) -> bool {
        self.deadline
            .is_some_and(|deadline| Instant::now() >= deadline)
            || (self.cancelled.as_ref()).is_some_and(|c| c.load(Ordering::Relaxed))
    }
}

/// Larger files are skipped (a book-length text is a few MB)
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;

/// A `file://` URI for a path, percent-encoding what URIs can't hold (like spaces)
pub fn path_to_uri(path: &Path) -> Option<Uri> {
    let path = path.to_str()?;
    let mut encoded = String::from("file://");
    if !path.starts_with('/') {
        // Windows: file:///C:/...
        encoded.push('/');
    }
    for byte in path.replace('\\', "/").bytes() {
        if byte.is_ascii_alphanumeric() || b"-._~/:".contains(&byte) {
            encoded.push(byte as char);
        } else {
            encoded.push_str(&format!("%{byte:02X}"));
        }
    }
    Uri::from_str(&encoded).ok()
}

/// The path of a `file://` URI
pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
    let text = uri.as_str().strip_prefix("file://")?;
    let bytes = text.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%'
            && let Some(byte) = text
                .get(i + 1..i + 3)
                .and_then(|hex| u8::from_str_radix(hex, 16).ok())
        {
            decoded.push(byte);
            i += 3;
            continue;
        }
        decoded.push(bytes[i]);
        i += 1;
    }
    let path = String::from_utf8(decoded).ok()?;
    // Windows: /C:/... is C:/...
    let path = match path.as_bytes() {
        [b'/', _, b':', ..] => path[1..].to_string(),
        _ => path,
    };
    Some(PathBuf::from(path))
}

/// A reference found in a file: where it is (in LSP positions) and what it is
#[derive(Clone, Debug)]
pub struct Found {
    pub range: Range,
    pub passage: Passage,
}

/// Each file's references, found once and reused while the file is unchanged
struct Entry {
    size: u64,
    modified: Option<SystemTime>,
    found: Arc<Vec<Found>>,
}

/**
A cache of every workspace file's references, before any filter (searches filter them), so a
search reads and parses only the files that changed since the last one
- Files are matched by size and modification time, like the CLI's `--cache`
- A new one when the data changes, since that changes what is found (a search still running with
  the old data fills the old one)
*/
#[derive(Default)]
pub struct Cache {
    files: Mutex<HashMap<PathBuf, Entry>>,
}

impl Cache {
    fn get(&self, path: &Path, size: u64, modified: Option<SystemTime>) -> Option<Arc<Vec<Found>>> {
        let files = self.files.lock().ok()?;
        let entry = files.get(path)?;
        (entry.size == size && entry.modified == modified).then(|| entry.found.clone())
    }

    fn insert(
        &self,
        path: PathBuf,
        size: u64,
        modified: Option<SystemTime>,
        found: Arc<Vec<Found>>,
    ) {
        if let Ok(mut files) = self.files.lock() {
            files.insert(
                path,
                Entry {
                    size,
                    modified,
                    found,
                },
            );
        }
    }

    /// Forgets files that are gone (or no longer searched)
    fn retain(&self, seen: &HashSet<PathBuf>) {
        if let Ok(mut files) = self.files.lock() {
            files.retain(|path, _| seen.contains(path));
        }
    }

    #[cfg(test)]
    pub fn len(&self) -> usize {
        self.files.lock().map_or(0, |files| files.len())
    }
}

/// A file and its references
pub type FileReferences = (Uri, Arc<Vec<Found>>);

/// The references in a text, with LSP ranges
pub fn find(matcher: &BibleMatcher, text: &str) -> Vec<Found> {
    let index = LineIndex::new(text);
    matcher
        .search(text)
        .into_iter()
        .map(|m| {
            let bytes = m.location.bytes;
            Found {
                range: crate::server::lsp_range(&index, bytes.start, bytes.end),
                passage: m.psg,
            }
        })
        .collect()
}

/// Whether a file is one of the Bible's books (by its canonical folder, since either path may go
/// through a symlink)
fn in_bible(path: &Path, folder: &Path) -> bool {
    let usx = path
        .extension()
        .is_some_and(|e| e.eq_ignore_ascii_case("usx"));
    usx && path
        .parent()
        .and_then(|parent| parent.canonicalize().ok())
        .is_some_and(|parent| parent == folder)
}

/**
Every reference in the workspace, by file: open documents as they are in the editor (unsaved
text included), then the files under `roots` that `scope` takes, from the cache when unchanged.
Files are walked in parallel
- With [`Scope::books`], EPUBs and PDFs too, as `logoxide-file://` documents
- The USX files in `bible` (the Bible's canonical folder) are left out
- When `stop` is reached, it stops walking; the `bool` is whether it searched every file
*/
// `Uri` caches its parsed parts, which clippy counts as a mutable key; its text never changes
#[allow(clippy::mutable_key_type)]
pub fn references(
    roots: &[PathBuf],
    documents: &HashMap<Uri, String>,
    scope: Scope,
    matcher: &BibleMatcher,
    cache: &Cache,
    bibles: &[&Path],
    stop: &Stop,
) -> (Vec<FileReferences>, bool) {
    // Open documents by path: editors may percent-encode their URIs differently
    let open: HashSet<PathBuf> = documents.keys().filter_map(uri_to_path).collect();
    // Unnamed documents (`file://`) are left out: there is no file to go to
    let results: Mutex<Vec<(Uri, Arc<Vec<Found>>)>> = Mutex::new(
        documents
            .iter()
            .filter(|(uri, _)| !crate::server::is_unnamed(uri))
            .map(|(uri, text)| (uri.clone(), Arc::new(find(matcher, text))))
            .collect(),
    );
    let seen: Mutex<HashSet<PathBuf>> = Mutex::new(HashSet::new());
    // The CLI's walking rules (ignore files, `.toposignore`, hidden files)
    let walk = WalkOptions {
        max_filesize: Some(MAX_FILE_SIZE),
        ..WalkOptions::default()
    };
    let Ok(builder) = walk.builder(roots) else {
        return (results.into_inner().unwrap_or_default(), true);
    };
    let Scope { extensions, books } = scope;
    let stopped = AtomicBool::new(false);
    builder.build_parallel().run(|| {
        Box::new(|entry| {
            if stop.reached() {
                stopped.store(true, Ordering::Relaxed);
                return WalkState::Quit;
            }
            let Ok(entry) = entry else {
                return WalkState::Continue;
            };
            let path = entry.path();
            let book = books.is_some_and(|b| b.searches(path));
            let wanted = entry.file_type().is_some_and(|t| t.is_file())
                && !open.contains(path)
                && !bibles.iter().any(|folder| in_bible(path, folder))
                && (book
                    || extensions.is_empty()
                    || path
                        .extension()
                        .and_then(|e| e.to_str())
                        .is_some_and(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e))));
            if !wanted {
                return WalkState::Continue;
            }
            let uri = match book {
                true => book_uri(path),
                false => path_to_uri(path),
            };
            let (Some(uri), Ok(meta)) = (uri, entry.metadata()) else {
                return WalkState::Continue;
            };
            // A file under two roots (one inside another) is searched once
            if let Ok(mut seen) = seen.lock()
                && !seen.insert(path.to_path_buf())
            {
                return WalkState::Continue;
            }
            let (size, modified) = (meta.len(), meta.modified().ok());
            let found = match cache.get(path, size, modified) {
                Some(found) => found,
                None if book => {
                    let disk = books.and_then(|b| b.disk.as_ref());
                    let found = Arc::new(book_references(matcher, disk, path));
                    cache.insert(path.to_path_buf(), size, modified, found.clone());
                    found
                }
                None => {
                    let Ok(bytes) = std::fs::read(path) else {
                        return WalkState::Continue;
                    };
                    // Like ripgrep, skip files that look binary (cached as having none)
                    let found = if bytes[..bytes.len().min(8192)].contains(&0) {
                        Arc::new(vec![])
                    } else {
                        Arc::new(find(matcher, &String::from_utf8_lossy(&bytes)))
                    };
                    cache.insert(path.to_path_buf(), size, modified, found.clone());
                    found
                }
            };
            if !found.is_empty()
                && let Ok(mut results) = results.lock()
            {
                results.push((uri, found));
            }
            WalkState::Continue
        })
    });
    let complete = !stopped.into_inner();
    // Files not reached yet may still be there
    if complete {
        cache.retain(&seen.into_inner().unwrap_or_default());
        if let Some(disk) = books.and_then(|b| b.disk.as_ref()) {
            // Now and then, entries for books that are gone
            disk.finish();
        }
    }
    (results.into_inner().unwrap_or_default(), complete)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uris_round_trip() {
        let path = Path::new("/home/me/My Notes/Romans 8 (2025)#1.md");
        let uri = path_to_uri(path).unwrap();
        assert_eq!(
            uri.as_str(),
            "file:///home/me/My%20Notes/Romans%208%20%282025%29%231.md"
        );
        assert_eq!(uri_to_path(&uri).unwrap(), path);
        let book = Path::new("/home/me/My Books/Gill.epub");
        let uri = book_uri(book).unwrap();
        assert_eq!(
            uri.as_str(),
            "logoxide-file:///home/me/My%20Books/Gill.epub"
        );
        assert_eq!(book_path(uri.as_str()).unwrap(), book);
        assert!(book_path("file:///home/me/a.epub").is_none());
    }
}