feat(cli): add command-line options

Parse arguments with lexopt: --cache-dir overrides the on-disk schema
cache location, --offline refuses every network fetch including the
json-ls.downloadSchema command, and --stdio is accepted as a no-op so
editor clients that always pass it keep working. Also wire up --help
and --version.

Offline is enforced at request_download, the sole network entry point,
so no document reaches the network regardless of the fetch policy.
This commit is contained in:
2026-07-04 03:54:06 +02:00
parent 1350f762e6
commit 137483807d
7 changed files with 128 additions and 5 deletions
Generated
+7
View File
@@ -98,12 +98,19 @@ name = "json-ls"
version = "1.0.0"
dependencies = [
"crossbeam-channel",
"lexopt",
"regex",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "lexopt"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413"
[[package]]
name = "libc"
version = "0.2.186"
+1
View File
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "3", default-features = false, features = ["rustls"] }
crossbeam-channel = "0.5"
lexopt = "0.3"
regex = { version = "1", default-features = false, features = [
"std",
"perf",
+16 -2
View File
@@ -32,8 +32,22 @@ Requires a recent stable Rust toolchain (edition 2024).
cargo build --release
```
The binary lands at `target/release/json-ls`. It takes no arguments and
communicates over stdin/stdout, so point your editor's LSP client at it.
The binary lands at `target/release/json-ls`. It communicates over
stdin/stdout, so point your editor's LSP client at it.
## Usage
```sh
json-ls [OPTIONS]
```
| Option | Effect |
| ------------------- | ------------------------------------------------------------------------------- |
| `--cache-dir <DIR>` | Directory for the on-disk schema cache. Defaults to the location below. |
| `--offline` | Refuse every network fetch, including the `json-ls.downloadSchema` command. |
| `--stdio` | Accepted for editor-client compatibility. Has no effect, since stdio is the only transport. |
| `-h`, `--help` | Print help. |
| `-V`, `--version` | Print version. |
## Schemas
+76 -2
View File
@@ -14,6 +14,7 @@ mod test_support;
use std::io;
use std::io::Write;
use std::path::PathBuf;
use std::thread;
use crossbeam_channel::Sender;
@@ -34,14 +35,87 @@ enum Event {
WorkerGone,
}
struct Args {
/// Overrides the on-disk schema cache location. `None` keeps the default.
cache_dir: Option<PathBuf>,
offline: bool,
}
const HELP: &str = "\
json-ls, a JSON/JSONC language server
Usage: json-ls [OPTIONS]
The server speaks LSP over stdin/stdout. Point your editor's LSP client at it.
Options:
--cache-dir <DIR> Directory for the on-disk schema cache
[default: $XDG_CACHE_HOME/json-ls/schemas]
--offline Refuse every network fetch, including the
json-ls.downloadSchema command
--stdio Accepted for editor-client compatibility (no effect)
-h, --help Print help
-V, --version Print version
";
fn parse_args() -> Result<Args, lexopt::Error> {
use lexopt::prelude::Long;
use lexopt::prelude::Short;
let mut cache_dir = None;
let mut offline = false;
let mut parser = lexopt::Parser::from_env();
while let Some(arg) = parser.next()? {
match arg {
Short('h') | Long("help") => {
print!("{HELP}");
std::process::exit(0);
}
Short('V') | Long("version") => {
println!("json-ls {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
Long("cache-dir") => {
cache_dir = Some(PathBuf::from(parser.value()?));
}
Long("offline") => offline = true,
// Editor clients often spawn the server with `--stdio`; accept and
// ignore it since stdio is the only transport.
Long("stdio") => {}
_ => return Err(arg.unexpected()),
}
}
Ok(Args { cache_dir, offline })
}
fn main() {
let args = match parse_args() {
Ok(args) => args,
Err(e) => {
eprintln!("json-ls: {e}");
eprintln!("Try 'json-ls --help' for more information.");
std::process::exit(2);
}
};
let (incoming_tx, incoming_rx) = unbounded::<IncomingMessage>();
let reader = thread::spawn(move || read_stdin(&incoming_tx));
let cache_dir = fetch::default_cache_dir();
let cache_dir = match args.cache_dir {
Some(dir) => {
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!(
"json-ls: cannot create cache dir {}: {e}",
dir.display()
);
}
Some(dir)
}
None => fetch::default_cache_dir(),
};
let (fetch_tx, fetch_rx) = fetch::spawn_worker(cache_dir.clone());
let mut server = Server::new(fetch_tx, cache_dir);
let mut server = Server::new(fetch_tx, cache_dir, args.offline);
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
+25
View File
@@ -74,6 +74,9 @@ pub struct SchemaStore {
/// still resolve to their own file.
cache_dir: Option<PathBuf>,
policy: FetchPolicy,
/// When set, the `request_download` network entry point is refused, so no
/// document ever reaches the network regardless of the fetch policy.
offline: bool,
fetch_tx: Sender<FetchRequest>,
}
@@ -90,6 +93,7 @@ impl SchemaStore {
workspace_roots: Vec::new(),
cache_dir: None,
policy: FetchPolicy::default(),
offline: false,
fetch_tx,
}
}
@@ -102,6 +106,10 @@ impl SchemaStore {
self.workspace_roots = roots;
}
pub fn set_offline(&mut self, offline: bool) {
self.offline = offline;
}
pub fn set_fetch_policy(&mut self, policy: FetchPolicy) {
self.policy = policy;
}
@@ -160,6 +168,9 @@ impl SchemaStore {
/// The one entry point that allows a network fetch. Returns whether a
/// download was started.
pub fn request_download(&mut self, doc_uri: &str) -> bool {
if self.offline {
return false;
}
let Some(url) = self.doc_schema.get(doc_uri).cloned() else {
return false;
};
@@ -452,6 +463,20 @@ mod tests {
assert!(download.allow_network, "download must allow the network");
}
#[test]
fn offline_refuses_the_download_entry_point() {
let (tx, rx) = unbounded::<FetchRequest>();
let mut store = SchemaStore::new(tx);
store.set_offline(true);
let d = doc(r#"{"$schema": "https://ex.com/s.json"}"#);
store.bind_document(&d);
let auto = rx.try_iter().next().unwrap();
store.on_fetched(FetchResult::NotCached { url: auto.url });
assert!(!store.request_download("file:///dir/data.json"));
assert_eq!(rx.try_iter().count(), 0, "offline must not enqueue");
}
#[test]
fn download_during_cache_probe_escalates_on_miss() {
let (tx, rx) = unbounded::<FetchRequest>();
+2
View File
@@ -115,9 +115,11 @@ impl Server {
pub fn new(
fetch_tx: Sender<FetchRequest>,
cache_dir: Option<PathBuf>,
offline: bool,
) -> Self {
let mut schema_store = SchemaStore::new(fetch_tx);
schema_store.set_cache_dir(cache_dir);
schema_store.set_offline(offline);
Self {
initialized: false,
shutdown_requested: false,
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::server::Server;
/// fetch channel stays connected for the duration of the test.
pub fn new_server() -> (Server, Receiver<FetchRequest>) {
let (tx, rx) = unbounded::<FetchRequest>();
(Server::new(tx, None), rx)
(Server::new(tx, None, false), rx)
}
pub fn request(id: i64, method: &str, params: Value) -> IncomingMessage {