Skip to content

Commit 9720cec

Browse files
authored
Merge pull request #301 from blopker/background-dictionary-prefetch
Move dictionary downloads off the spell-check path
2 parents 94e9f74 + d6ac9b7 commit 9720cec

20 files changed

Lines changed: 1997 additions & 534 deletions

File tree

Cargo.lock

Lines changed: 199 additions & 175 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ tree-sitter-cpp = "<0.25.0"
5959
tree-sitter-css = "<0.26.0"
6060
tree-sitter-dart = "<1"
6161
tree-sitter-elixir = "<0.4.0"
62-
tree-sitter-erlang = "<0.20.0"
62+
tree-sitter-erlang = "<0.21.0"
6363
tree-sitter-go = "<0.26.0"
6464
tree-sitter-haskell = "<0.25.0"
6565
tree-sitter-html = "<0.25.0"

crates/codebook-config/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ pub trait CodebookConfig: Sync + Send + Debug {
4747
fn add_ignore(&self, file: &str) -> bool;
4848
fn add_include(&self, file: &str) -> bool;
4949
fn get_dictionary_ids(&self) -> Vec<String>;
50+
/// Every dictionary ID this config can resolve to for any file,
51+
/// including per-path override blocks. Used for prefetching.
52+
fn all_dictionary_ids(&self) -> Vec<String> {
53+
self.get_dictionary_ids()
54+
}
5055
fn should_ignore_path(&self, path: &Path) -> bool;
5156
fn should_include_path(&self, path: &Path) -> bool;
5257
fn is_allowed_word(&self, word: &str) -> bool;
@@ -508,6 +513,10 @@ impl CodebookConfig for CodebookConfigFile {
508513
snapshot.dictionary_ids()
509514
}
510515

516+
fn all_dictionary_ids(&self) -> Vec<String> {
517+
self.snapshot().all_dictionary_ids()
518+
}
519+
511520
/// Check if a path is included based on the effective configuration
512521
fn should_include_path(&self, path: &Path) -> bool {
513522
let snapshot = self.snapshot();
@@ -624,6 +633,10 @@ impl CodebookConfig for CodebookConfigMemory {
624633
snapshot.dictionary_ids()
625634
}
626635

636+
fn all_dictionary_ids(&self) -> Vec<String> {
637+
self.snapshot().all_dictionary_ids()
638+
}
639+
627640
fn should_include_path(&self, path: &Path) -> bool {
628641
let snapshot = self.snapshot();
629642
snapshot.should_include_path(path)

crates/codebook-config/src/settings.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,21 @@ impl ConfigSettings {
492492
}
493493
}
494494

495+
/// Every dictionary ID this config can resolve to for any file: the base
496+
/// ids plus each override block's `dictionaries` and `extra_dictionaries`.
497+
/// Sorted and deduped. Used to prefetch dictionaries before a matching
498+
/// file is ever opened.
499+
pub fn all_dictionary_ids(&self) -> Vec<String> {
500+
let mut ids = self.dictionary_ids();
501+
for block in &self.overrides {
502+
ids.extend(block.dictionaries.iter().flatten().cloned());
503+
ids.extend(block.extra_dictionaries.iter().flatten().cloned());
504+
}
505+
ids.sort();
506+
ids.dedup();
507+
ids
508+
}
509+
495510
/// Determine whether a path should be included based on the configured glob patterns.
496511
pub fn should_include_path(&self, path: &Path) -> bool {
497512
if self.include_paths.is_empty() {
@@ -1286,6 +1301,42 @@ mod tests {
12861301
assert_eq!(resolved.words, vec!["codebook"]);
12871302
}
12881303

1304+
#[test]
1305+
fn test_all_dictionary_ids_unions_base_and_overrides() {
1306+
let settings = ConfigSettings {
1307+
dictionaries: vec!["en_us".to_string(), "es".to_string()],
1308+
overrides: vec![
1309+
OverrideBlock {
1310+
paths: vec![glob("docs/**")],
1311+
dictionaries: Some(vec!["de".to_string(), "en_us".to_string()]),
1312+
..OverrideBlock::default_for_test()
1313+
},
1314+
OverrideBlock {
1315+
paths: vec![glob("**/*.md")],
1316+
extra_dictionaries: Some(vec!["fr".to_string()]),
1317+
..OverrideBlock::default_for_test()
1318+
},
1319+
],
1320+
..Default::default()
1321+
};
1322+
1323+
assert_eq!(settings.all_dictionary_ids(), ["de", "en_us", "es", "fr"]);
1324+
}
1325+
1326+
#[test]
1327+
fn test_all_dictionary_ids_includes_implicit_default() {
1328+
let settings = ConfigSettings {
1329+
overrides: vec![OverrideBlock {
1330+
paths: vec![glob("**/*.md")],
1331+
extra_dictionaries: Some(vec!["es".to_string()]),
1332+
..OverrideBlock::default_for_test()
1333+
}],
1334+
..Default::default()
1335+
};
1336+
1337+
assert_eq!(settings.all_dictionary_ids(), ["en_us", "es"]);
1338+
}
1339+
12891340
#[test]
12901341
fn test_merge_preserves_override_order() {
12911342
let mut global = ConfigSettings {

crates/codebook-lsp/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pub mod file_cache;
22
mod init_options;
33
pub mod lsp;
44
pub mod lsp_logger;
5+
mod prefetch;

crates/codebook-lsp/src/lint.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ pub fn run_lint(files: &[String], root: &Path, unique: bool, suggest: bool) -> L
104104

105105
let codebook = Codebook::new(config.clone());
106106

107+
// The lint run is one-shot, so unlike the LSP it blocks on downloads.
108+
// Individual failures degrade to checking with whatever is available —
109+
// but with no primary dictionary at all, every check noops, and
110+
// reporting that as a clean run would be a false green in CI.
111+
let warmup = codebook.warm_dictionaries();
112+
if warmup.network_disabled {
113+
eprintln!("NO_NETWORK set; checking with cached dictionaries only");
114+
}
115+
for (id, e) in &warmup.failures {
116+
err!("could not download dictionary '{id}': {e} (checking with cached/available dictionaries)");
117+
}
118+
if !warmup.primary_available {
119+
err!(
120+
"no configured dictionary is available (downloads failed, or the cache is empty with NO_NETWORK set); cannot check spelling"
121+
);
122+
return LintResult::Failure;
123+
}
124+
107125
// Canonicalize the root once here rather than once per file.
108126
let root_canonical = root.canonicalize().ok();
109127

crates/codebook-lsp/src/lsp.rs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use log::{debug, info};
2424
use crate::file_cache::TextDocumentCache;
2525
use crate::init_options::ClientInitializationOptions;
2626
use crate::lsp_logger;
27+
use crate::prefetch::{self, PrefetchHandle};
2728

2829
const SOURCE_NAME: &str = "Codebook";
2930

@@ -62,6 +63,23 @@ fn compute_relative_path(
6263
}
6364

6465
pub struct Backend {
66+
state: Arc<BackendState>,
67+
}
68+
69+
/// `Backend` is a pointer newtype over the shared state; Deref keeps every
70+
/// handler spelling `self.field` instead of `self.state.field`.
71+
impl std::ops::Deref for Backend {
72+
type Target = BackendState;
73+
fn deref(&self) -> &Self::Target {
74+
&self.state
75+
}
76+
}
77+
78+
/// Shared server state, `Arc`'d so tasks outside the LSP dispatch loop (the
79+
/// prefetch worker's recheck listener) can reach the same document cache,
80+
/// config, and client. Public only because `Deref<Target = BackendState>`
81+
/// requires it; all fields are private.
82+
pub struct BackendState {
6583
client: Client,
6684
workspace_dir: PathBuf,
6785
/// Cached canonicalized workspace directory for efficient relative path computation
@@ -72,6 +90,9 @@ pub struct Backend {
7290
initialize_options: RwLock<Arc<ClientInitializationOptions>>,
7391
/// When the config files were last polled for changes (None = never)
7492
last_config_poll: Mutex<Option<Instant>>,
93+
/// Handle to the background dictionary prefetch worker (None until
94+
/// `initialized`, or forever when NO_NETWORK is set)
95+
prefetch: OnceLock<PrefetchHandle>,
7596
}
7697

7798
enum CodebookCommand {
@@ -169,6 +190,7 @@ impl LanguageServer for Backend {
169190
"Global config: {}",
170191
config.global_config_path().unwrap_or_default().display()
171192
);
193+
self.spawn_prefetch();
172194
}
173195

174196
async fn shutdown(&self) -> RpcResult<()> {
@@ -388,17 +410,45 @@ impl Backend {
388410
pub fn new(client: Client, workspace_dir: &Path) -> Self {
389411
let workspace_dir_canonical = workspace_dir.canonicalize().ok();
390412
Self {
391-
client,
392-
workspace_dir: workspace_dir.to_path_buf(),
393-
workspace_dir_canonical,
394-
codebook: OnceLock::new(),
395-
config: OnceLock::new(),
396-
document_cache: TextDocumentCache::default(),
397-
initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
398-
last_config_poll: Mutex::new(None),
413+
state: Arc::new(BackendState {
414+
client,
415+
workspace_dir: workspace_dir.to_path_buf(),
416+
workspace_dir_canonical,
417+
codebook: OnceLock::new(),
418+
config: OnceLock::new(),
419+
document_cache: TextDocumentCache::default(),
420+
initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
421+
last_config_poll: Mutex::new(None),
422+
prefetch: OnceLock::new(),
423+
}),
424+
}
425+
}
426+
427+
/// Start the background dictionary prefetch worker and the task that
428+
/// re-checks open documents when a new dictionary lands.
429+
fn spawn_prefetch(&self) {
430+
if self.prefetch.get().is_some() {
431+
return;
399432
}
433+
if codebook::network_disabled() {
434+
info!("NO_NETWORK set; dictionary downloads disabled");
435+
return;
436+
}
437+
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
438+
let state = self.state.clone();
439+
tokio::spawn(async move {
440+
while rx.recv().await.is_some() {
441+
// Coalesce a burst of completions into one recheck
442+
while rx.try_recv().is_ok() {}
443+
state.recheck_all().await;
444+
}
445+
});
446+
let handle = prefetch::spawn(self.codebook_handle(), tx);
447+
let _ = self.prefetch.set(handle);
400448
}
449+
}
401450

451+
impl BackendState {
402452
fn config_handle(&self) -> Arc<CodebookConfigFile> {
403453
self.config
404454
.get_or_init(|| {
@@ -556,6 +606,11 @@ impl Backend {
556606

557607
if did_reload {
558608
debug!("Config reloaded, rechecking all files.");
609+
// The change may add dictionaries: wake the prefetch worker and
610+
// reset its backoff so they download immediately.
611+
if let Some(handle) = self.prefetch.get() {
612+
handle.kick();
613+
}
559614
self.recheck_all().await;
560615
} else {
561616
debug!("Checking file: {uri:?}");

crates/codebook-lsp/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod init_options;
33
mod lint;
44
mod lsp;
55
mod lsp_logger;
6+
mod prefetch;
67

78
use clap::{Parser, Subcommand};
89
use codebook_config::{CodebookConfig, CodebookConfigFile, ConfigError};

0 commit comments

Comments
 (0)