2 Commits
Author SHA1 Message Date
oscar b6145f2e03 chore: release v1.3.0 2026-07-18 21:55:59 +02:00
oscar 0875c682a0 feat(schema): reload a changed schema on document open
An update made in one server instance reaches the others through the
shared disk cache the next time a document opens.
2026-07-18 21:55:40 +02:00
8 changed files with 221 additions and 81 deletions
Generated
+1 -1
View File
@@ -95,7 +95,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "json-ls"
version = "1.2.0"
version = "1.3.0"
dependencies = [
"crossbeam-channel",
"lexopt",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "json-ls"
version = "1.2.0"
version = "1.3.0"
edition = "2024"
description = "A JSON/JSONC language server"
license = "Apache-2.0"
+3 -1
View File
@@ -64,7 +64,9 @@ Relative and `file://` URLs resolve against the document. Workspace-local
schema files load automatically. Remote schemas are served from the on-disk
cache when present, and are otherwise offered as a code lens and code action.
Once a schema is loaded, the same lens and action offer to update it, which
re-fetches the schema and overwrites the cached copy. Opening or editing a
re-fetches the schema and overwrites the cached copy. Opening a document
whose schema's cached or local file changed on disk reloads it, so an
update made by another server instance is picked up. Opening or editing a
document never touches the network: only accepting a download or update
offer does, via the `json-ls.downloadSchema` command.
+1
View File
@@ -120,6 +120,7 @@ mod tests {
let mut store = store();
store.bind_document(&doc);
store.on_fetched(FetchResult::Ok {
mtime: None,
url: "https://ex.com/s.json".to_owned(),
root: serde_json::json!({}),
});
+61 -52
View File
@@ -20,11 +20,15 @@ const DOC_URI: &str = "file:///t.json";
const SCHEMA_URL: &str = "https://example.test/schema.json";
fn open(server: &mut Server, text: &str) -> Vec<Outgoing> {
open_at(server, DOC_URI, text)
}
fn open_at(server: &mut Server, uri: &str, text: &str) -> Vec<Outgoing> {
server.handle(notification(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": DOC_URI,
"uri": uri,
"languageId": "json",
"version": 1,
"text": text
@@ -33,6 +37,17 @@ fn open(server: &mut Server, text: &str) -> Vec<Outgoing> {
))
}
fn download_schema(server: &mut Server, id: i64) -> Vec<Outgoing> {
server.handle(request(
id,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
))
}
fn find_request<'a>(outgoing: &'a [Outgoing], method: &str) -> &'a Request {
outgoing
.iter()
@@ -96,6 +111,7 @@ fn ready_server(
open(&mut server, doc_text);
let fetch = rx.try_recv().expect("a schema fetch was requested");
server.handle_fetch(FetchResult::Ok {
mtime: None,
url: fetch.url,
root: schema,
});
@@ -157,18 +173,12 @@ fn schema_is_fetched_only_on_the_download_command() {
json!("json-ls.downloadSchema")
);
server.handle(request(
3,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
));
download_schema(&mut server, 3);
let download = rx.try_recv().expect("the command started a download");
assert!(download.allow_network, "the command must allow the network");
let out = server.handle_fetch(FetchResult::Ok {
mtime: None,
url: download.url,
root: json!({"properties": {"name": {"type": "string"}}}),
});
@@ -187,6 +197,7 @@ fn update_command_refetches_a_loaded_schema() {
open(&mut server, &doc_with_schema(r#""name": 42"#));
let auto = rx.try_recv().expect("a cache-only load was requested");
server.handle_fetch(FetchResult::Ok {
mtime: None,
url: auto.url,
root: json!({}),
});
@@ -200,17 +211,11 @@ fn update_command_refetches_a_loaded_schema() {
assert_eq!(lenses.len(), 1);
assert_eq!(lenses[0]["command"]["title"], json!("Update schema"));
server.handle(request(
3,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
));
download_schema(&mut server, 3);
let update = rx.try_recv().expect("the command started an update");
assert!(update.allow_network, "the update must allow the network");
let out = server.handle_fetch(FetchResult::Ok {
mtime: None,
url: update.url,
root: json!({"properties": {"name": {"type": "string"}}}),
});
@@ -231,6 +236,7 @@ fn schema_fetch_produces_validation_diagnostics() {
// inspect the diagnostics republished as a result.
let fetch = rx.try_recv().expect("a schema fetch was requested");
let out = server.handle_fetch(FetchResult::Ok {
mtime: None,
url: fetch.url,
root: json!({"properties": {"name": {"type": "string"}}}),
});
@@ -255,14 +261,7 @@ fn failed_download_shows_an_error_message() {
});
assert!(show_messages(&out).is_empty());
server.handle(request(
2,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
));
download_schema(&mut server, 2);
let download = rx.try_recv().expect("the command started a download");
let out = server.handle_fetch(FetchResult::Err {
url: download.url,
@@ -318,6 +317,7 @@ fn approved_redirect_resumes_the_download() {
// The schema fetched from the redirect target binds and validates under
// the URL the document declares.
let out = server.handle_fetch(FetchResult::Ok {
mtime: None,
url: resumed.url,
root: json!({"properties": {"name": {"type": "string"}}}),
});
@@ -337,14 +337,7 @@ fn declined_redirect_leaves_the_download_retryable() {
server.handle(response(&prompt_id, Value::Null));
assert!(rx.try_recv().is_err(), "a decline must not fetch");
server.handle(request(
3,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
));
download_schema(&mut server, 3);
let retried = rx.try_recv().expect("a fresh download after the decline");
assert_eq!(retried.hops, 0);
}
@@ -374,6 +367,38 @@ fn completion_offers_enum_values() {
assert!(labels.contains(&"\"green\""));
}
#[test]
fn update_from_one_document_refreshes_another() {
let schema = json!({"properties": {"name": {"type": "string"}}});
let text = doc_with_schema(r#""a": 1"#);
let (mut server, rx) = ready_server(&text, schema);
let other_uri = "file:///u.json";
open_at(&mut server, other_uri, &text);
assert!(rx.try_recv().is_err(), "no fetch for a loaded schema");
download_schema(&mut server, 2);
let update = rx.try_recv().expect("an update fetch");
server.handle_fetch(FetchResult::Ok {
mtime: None,
url: update.url,
root: json!({"properties": {"color": {"type": "string"}}}),
});
let out = server.handle(request(
3,
"textDocument/completion",
json!({
"textDocument": {"uri": other_uri},
"position": {"line": 0, "character": 1}
}),
));
let items = response_result(&out).as_array().unwrap();
let labels: Vec<&str> =
items.iter().map(|i| i["label"].as_str().unwrap()).collect();
assert!(labels.contains(&"color"), "labels were: {labels:?}");
}
#[test]
fn completion_documentation_arrives_via_resolve() {
let schema = json!({
@@ -489,21 +514,12 @@ fn definition_jumps_from_a_data_key_to_the_schema() {
let (mut server, rx) = new_server();
server.handle(request(1, "initialize", json!({})));
let data_text = format!(r#"{{"$schema": "{schema_uri}", "name": "x"}}"#);
server.handle(notification(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": data_uri,
"languageId": "json",
"version": 1,
"text": data_text
}
}),
));
open_at(&mut server, &data_uri, &data_text);
// The file schema auto-loads because it sits in the document's directory.
// Satisfy it.
let fetch = rx.try_recv().expect("a schema fetch was requested");
server.handle_fetch(FetchResult::Ok {
mtime: None,
url: fetch.url,
root: serde_json::from_str(schema_body).unwrap(),
});
@@ -600,14 +616,7 @@ fn download_request(
open(server, text);
let probe = rx.try_recv().expect("a cache-only load was requested");
server.handle_fetch(FetchResult::NotCached { url: probe.url });
server.handle(request(
9,
"workspace/executeCommand",
json!({
"command": "json-ls.downloadSchema",
"arguments": [DOC_URI]
}),
));
download_schema(server, 9);
rx.try_recv().expect("the command started a download")
}
+57 -21
View File
@@ -7,6 +7,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use std::time::SystemTime;
use crossbeam_channel::Receiver;
use crossbeam_channel::Sender;
@@ -129,11 +130,12 @@ pub enum FetchResult {
Ok {
url: String,
root: Value,
/// Modification time of the backing file the content was read from,
/// captured at read time so it never describes a newer copy.
mtime: Option<SystemTime>,
},
/// A cache-only load found nothing, so the user must request a download.
NotCached {
url: String,
},
NotCached { url: String },
/// The download hit a cross-origin redirect that needs the user's
/// approval before it continues.
Redirected {
@@ -186,17 +188,32 @@ pub fn default_cache_dir() -> Option<PathBuf> {
fn fetch_one(req: &FetchRequest, cache_dir: Option<&Path>) -> FetchResult {
let url = &req.url;
let cache_file = cache_dir.map(|dir| cache_path(dir, url));
let cache_file = if is_http_url(url) {
cache_dir.map(|dir| cache_path(dir, url))
} else {
None
};
// Mtimes are taken before their content is read, so a concurrent
// replacement can only make a copy look older than it is, never newer.
let local_mtime = resolve::file_url_to_path(url)
.and_then(|path| fs::metadata(path).ok())
.and_then(|meta| meta.modified().ok());
if !req.allow_network
&& let Some(path) = &cache_file
&& let Ok(bytes) = fs::read(path)
&& let Ok(root) = serde_json::from_slice::<Value>(&bytes)
{
return FetchResult::Ok {
url: url.clone(),
root,
};
let mtime = fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok());
if let Ok(bytes) = fs::read(path)
&& let Ok(root) = serde_json::from_slice::<Value>(&bytes)
{
return FetchResult::Ok {
url: url.clone(),
root,
mtime,
};
}
}
// A remote schema that is not cached needs an explicit download.
@@ -259,12 +276,14 @@ fn fetch_one(req: &FetchRequest, cache_dir: Option<&Path>) -> FetchResult {
match serde_json::from_str::<Value>(&body) {
Ok(root) => {
if let Some(path) = &cache_file {
write_cache(path, body.as_bytes());
}
let mtime = match &cache_file {
Some(path) => write_cache(path, body.as_bytes()),
None => local_mtime,
};
FetchResult::Ok {
url: url.clone(),
root,
mtime,
}
}
Err(e) => err(format!("invalid schema JSON: {e}")),
@@ -430,19 +449,29 @@ pub(crate) fn cache_path(dir: &Path, url: &str) -> PathBuf {
dir.join(format!("{hash:016x}.json"))
}
fn write_cache(path: &Path, bytes: &[u8]) {
fn write_cache(path: &Path, bytes: &[u8]) -> Option<SystemTime> {
if let Some(parent) = path.parent()
&& fs::create_dir_all(parent).is_err()
{
return;
return None;
}
// The rename swaps the copy in atomically, so a concurrent read never
// sees a partial write and a failure leaves the previous copy intact.
// The temp file is statted before the rename (which preserves mtime),
// so the returned mtime belongs to exactly this content.
let tmp = path.with_extension("json.tmp");
let written = fs::write(&tmp, bytes).and_then(|()| fs::rename(&tmp, path));
if let Err(e) = written {
drop(fs::remove_file(&tmp));
eprintln!("json-ls: failed to write schema cache: {e}");
let written = fs::write(&tmp, bytes).and_then(|()| {
let mtime = fs::metadata(&tmp)?.modified()?;
fs::rename(&tmp, path)?;
Ok(mtime)
});
match written {
Ok(mtime) => Some(mtime),
Err(e) => {
drop(fs::remove_file(&tmp));
eprintln!("json-ls: failed to write schema cache: {e}");
None
}
}
}
@@ -552,8 +581,9 @@ mod tests {
// A cache hit is returned even when the network is not allowed.
match fetch(url, false, FetchPolicy::default(), Some(&dir)) {
FetchResult::Ok { root, .. } => {
FetchResult::Ok { root, mtime, .. } => {
assert_eq!(root, json!({"type": "string"}));
assert!(mtime.is_some(), "a cache hit must carry its mtime");
}
other => panic!("expected cache hit: {}", describe(&other)),
}
@@ -567,8 +597,14 @@ mod tests {
fs::write(cache_path(&dir, &url), br#"{"type":"string"}"#).unwrap();
match fetch(&url, true, loopback_policy(), Some(&dir)) {
FetchResult::Ok { root, .. } => {
FetchResult::Ok { root, mtime, .. } => {
assert_eq!(root, json!({"type": "number"}));
let cached = cache_path(&dir, &url);
assert_eq!(
mtime,
Some(fs::metadata(cached).unwrap().modified().unwrap()),
"the mtime must match the written cache copy"
);
}
other => panic!("expected a fresh fetch: {}", describe(&other)),
}
+96 -5
View File
@@ -3,6 +3,7 @@ use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use crossbeam_channel::Sender;
use serde_json::Value;
@@ -65,6 +66,10 @@ pub struct SchemaStore {
/// editing does not repeat the cache probe or the workspace-containment
/// filesystem check on every change.
auto_attempted: HashSet<String>,
/// Modification time of each loaded schema's backing file, so opening a
/// document can spot a copy refreshed on disk by another server
/// instance.
source_mtime: HashMap<String, SystemTime>,
waiting: HashMap<String, HashSet<String>>,
doc_schema: HashMap<String, String>,
workspace_roots: Vec<PathBuf>,
@@ -88,6 +93,7 @@ impl SchemaStore {
pending: HashSet::new(),
want_network: HashSet::new(),
auto_attempted: HashSet::new(),
source_mtime: HashMap::new(),
waiting: HashMap::new(),
doc_schema: HashMap::new(),
workspace_roots: Vec::new(),
@@ -183,14 +189,18 @@ impl SchemaStore {
let Some(url) = self.doc_schema.get(doc_uri).cloned() else {
return false;
};
let docs = self.waiting.entry(url.clone()).or_default();
self.enroll_bound_documents(&url);
self.enqueue(url, true);
true
}
fn enroll_bound_documents(&mut self, url: &str) {
let docs = self.waiting.entry(url.to_owned()).or_default();
for (doc, bound) in &self.doc_schema {
if *bound == url {
if bound == url {
docs.insert(doc.clone());
}
}
self.enqueue(url, true);
true
}
pub fn schema_for(&self, doc_uri: &str) -> Option<Arc<SchemaDocument>> {
@@ -227,6 +237,10 @@ impl SchemaStore {
if !self.memory.contains_key(url) {
return None;
}
self.source_path_for_url(url)
}
fn source_path_for_url(&self, url: &str) -> Option<PathBuf> {
if let Some(path) = resolve::file_url_to_path(url) {
return Some(path);
}
@@ -235,6 +249,30 @@ impl SchemaStore {
.map(|dir| crate::schema::fetch::cache_path(dir, url))
}
/// Reload the schema bound to `doc_uri` from disk when its backing file
/// changed since it was loaded. Never fetches over the network.
pub fn reload_if_changed(&mut self, doc_uri: &str) {
let Some(url) = self.doc_schema.get(doc_uri) else {
return;
};
if !self.memory.contains_key(url) {
return;
}
let Some(mtime) = self
.source_path_for_url(url)
.and_then(|path| std::fs::metadata(path).ok())
.and_then(|meta| meta.modified().ok())
else {
return;
};
if self.source_mtime.get(url) == Some(&mtime) {
return;
}
let url = url.clone();
self.enroll_bound_documents(&url);
self.enqueue(url, false);
}
pub fn is_loaded(&self, doc_uri: &str) -> bool {
self.doc_schema
.get(doc_uri)
@@ -249,8 +287,16 @@ impl SchemaStore {
// the user's decision arrives via `follow_redirect` or
// `abandon_fetch`.
FetchResult::Redirected { .. } => Vec::new(),
FetchResult::Ok { url, root } => {
FetchResult::Ok { url, root, mtime } => {
self.pending.remove(&url);
match mtime {
Some(mtime) => {
self.source_mtime.insert(url.clone(), mtime);
}
None => {
self.source_mtime.remove(&url);
}
}
// A download requested while this load was in flight still
// needs the network: deliver this copy now, but keep the
// documents waiting for the fresh one.
@@ -264,6 +310,7 @@ impl SchemaStore {
.flatten()
.cloned()
.collect();
self.sources.remove(&url);
self.memory.insert(
url.clone(),
Arc::new(SchemaDocument { url, root }),
@@ -466,6 +513,7 @@ mod tests {
store.bind_document(&d);
let waiting = store.on_fetched(FetchResult::Ok {
mtime: None,
url: "https://ex.com/s.json".to_owned(),
root: json!({"type": "object"}),
});
@@ -519,6 +567,7 @@ mod tests {
store.bind_document(&other);
let probe = rx.try_iter().next().unwrap();
store.on_fetched(FetchResult::Ok {
mtime: None,
url: probe.url,
root: json!({}),
});
@@ -529,6 +578,7 @@ mod tests {
assert!(update.allow_network);
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: update.url,
root: json!({"type": "object"}),
});
@@ -579,6 +629,7 @@ mod tests {
assert_eq!(rx.try_iter().count(), 0);
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: probe.url,
root: json!({}),
});
@@ -587,6 +638,7 @@ mod tests {
assert!(escalated.allow_network);
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: escalated.url,
root: json!({"type": "object"}),
});
@@ -601,6 +653,7 @@ mod tests {
store.bind_document(&d);
let probe = rx.try_iter().next().unwrap();
store.on_fetched(FetchResult::Ok {
mtime: None,
url: probe.url,
root: json!({"type": "object"}),
});
@@ -608,6 +661,7 @@ mod tests {
assert!(store.request_download("file:///dir/data.json"));
let update = rx.try_iter().next().unwrap();
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: update.url,
root: json!({"type": "object"}),
});
@@ -623,6 +677,7 @@ mod tests {
store.bind_document(&d);
let probe = rx.try_iter().next().unwrap();
store.on_fetched(FetchResult::Ok {
mtime: None,
url: probe.url,
root: json!({}),
});
@@ -637,12 +692,47 @@ mod tests {
);
assert!(store.bind_document(&other).is_some());
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: update.url,
root: json!({"type": "object"}),
});
assert!(refreshed.contains(&"file:///dir/other.json".to_owned()));
}
#[test]
fn open_reloads_a_schema_whose_cache_copy_changed() {
let dir = temp_dir("recheck");
let (tx, rx) = unbounded::<FetchRequest>();
let mut store = SchemaStore::new(tx);
store.set_cache_dir(Some(dir.clone()));
let d = doc(r#"{"$schema": "https://ex.com/s.json"}"#);
store.bind_document(&d);
let probe = rx.try_iter().next().unwrap();
let cache = crate::schema::fetch::cache_path(&dir, &probe.url);
std::fs::write(&cache, b"{}").unwrap();
let mtime = std::fs::metadata(&cache).unwrap().modified().unwrap();
store.on_fetched(FetchResult::Ok {
mtime: Some(mtime),
url: probe.url.clone(),
root: json!({}),
});
store.reload_if_changed("file:///dir/data.json");
assert_eq!(rx.try_iter().count(), 0);
// Another instance replaced the cached copy.
store.source_mtime.insert(probe.url, std::time::UNIX_EPOCH);
store.reload_if_changed("file:///dir/data.json");
let reload = rx.try_iter().next().expect("a cache reload");
assert!(!reload.allow_network, "a reload must not hit the network");
let refreshed = store.on_fetched(FetchResult::Ok {
mtime: None,
url: reload.url,
root: json!({"type": "object"}),
});
assert!(refreshed.contains(&"file:///dir/data.json".to_owned()));
}
#[test]
fn redirected_download_stays_pending_until_decided() {
let (tx, rx) = unbounded::<FetchRequest>();
@@ -711,6 +801,7 @@ mod tests {
store.unbind_document("file:///dir/data.json");
let waiting = store.on_fetched(FetchResult::Ok {
mtime: None,
url: "https://ex.com/s.json".to_owned(),
root: json!({}),
});
+1
View File
@@ -341,6 +341,7 @@ impl Server {
self.documents
.open(uri.clone(), doc.version, doc.text, dialect);
let schema = self.bind_schema(&uri);
self.schema_store.reload_if_changed(&uri);
self.refresh_diagnostics_with(&uri, schema.as_deref())
}