From 0288bf301b92448e63566fb0dc248af91c821245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Sat, 4 Jul 2026 12:41:58 +0200 Subject: [PATCH 1/2] Add in-memory `validate_spec_str` entry point Editors and the language server need to validate an unsaved buffer, but `validate_spec` only takes a path and reads the file itself. Factor the parse + `SourceContext` construction out of `load` into `load_str`, and expose `validate_spec_str(content, filename)` as the content twin of `validate_spec` (no I/O, so it never reports `Io`). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/data-dict/src/lib.rs | 2 +- crates/data-dict/src/validate_spec.rs | 25 ++++++++++++++--- crates/data-dict/tests/validate_spec.rs | 36 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/data-dict/src/lib.rs b/crates/data-dict/src/lib.rs index 754950f..8f42aa6 100644 --- a/crates/data-dict/src/lib.rs +++ b/crates/data-dict/src/lib.rs @@ -26,8 +26,8 @@ pub use problem::{Problem, ProblemKind, ProblemSet, Severity, SpanLocation, Stat pub use quarto_source_map::SourceContext; pub use validate_data::validate_data; pub use validate_meta::validate_meta; -pub use validate_spec::validate_spec; pub(crate) use validate_spec::{load, validate_and_lower}; +pub use validate_spec::{validate_spec, validate_spec_str}; use model::{DataDict, Table}; diff --git a/crates/data-dict/src/validate_spec.rs b/crates/data-dict/src/validate_spec.rs index 357af09..17effe7 100644 --- a/crates/data-dict/src/validate_spec.rs +++ b/crates/data-dict/src/validate_spec.rs @@ -55,6 +55,17 @@ pub fn validate_spec(path: &Path) -> ProblemSet { problems } +/// The content twin of [`validate_spec`]: validates in-memory `content` (no I/O, +/// for an unsaved buffer), attributing problems to `filename`. +pub fn validate_spec_str(content: &str, filename: &str) -> ProblemSet { + let (mut problems, doc) = match load_str(content, filename) { + Ok(loaded) => loaded, + Err(problems) => return problems, + }; + validate_and_lower(&doc, &mut problems); + problems +} + /// Read, parse, and schema-check the document at `path`, creating the run's /// [`ProblemSet`] with the document's source — this is where every level starts. /// `Ok((problems, doc))` hands back the fresh set and the parsed AST to validate; @@ -66,8 +77,16 @@ pub(crate) fn load(path: &Path) -> Result<(ProblemSet, YamlWithSourceInfo), Prob Err(e) => return Err(ProblemSet::from_preflight(ProblemKind::Io, e.to_string())), }; let filename = path.display().to_string(); + load_str(&content, &filename) +} - let doc = match quarto_yaml::parse_file(&content, &filename) { +/// The content twin of [`load`]: parses and schema-checks in-memory `content` +/// without reading a file, so it never fails with [`ProblemKind::Io`]. +pub(crate) fn load_str( + content: &str, + filename: &str, +) -> Result<(ProblemSet, YamlWithSourceInfo), ProblemSet> { + let doc = match quarto_yaml::parse_file(content, filename) { Ok(doc) => doc, Err(e) => { return Err(ProblemSet::from_preflight( @@ -78,8 +97,8 @@ pub(crate) fn load(path: &Path) -> Result<(ProblemSet, YamlWithSourceInfo), Prob }; let mut source = SourceContext::new(); - let file_id = quarto_yaml::file_id_for_filename(&filename); - source.add_file_with_id(file_id, filename, Some(content)); + let file_id = quarto_yaml::file_id_for_filename(filename); + source.add_file_with_id(file_id, filename.to_string(), Some(content.to_string())); let registry = SchemaRegistry::new(); if let Err(err) = quarto_yaml_validation::validate(&doc, schema(), ®istry, &source) { diff --git a/crates/data-dict/tests/validate_spec.rs b/crates/data-dict/tests/validate_spec.rs index 177967f..4862a7f 100644 --- a/crates/data-dict/tests/validate_spec.rs +++ b/crates/data-dict/tests/validate_spec.rs @@ -887,3 +887,39 @@ fn s15_bad_time_zone() { #[cfg(unix)] assert_snapshot!(diagnostic); } + +// --- in-memory entry point ---------------------------------------------- + +#[test] +fn validate_spec_str_accepts_valid_content() { + let content = indoc! {" + $version: 0.1.0 + $learn_more: http://data-dict.tidyverse.org/ + tables: + - name: t + description: A table. + source: + parquet: t.parquet + columns: + - name: c + type: string + examples: [a, b] + description: A column. + "}; + let problems = data_dict::validate_spec_str(content, "buffer.yaml"); + assert!(!problems.status().failed()); +} + +#[test] +fn validate_spec_str_reports_located_schema_error() { + // Missing the required `$version` key: a structural schema failure that + // still resolves to a location in the buffer. + let problems = data_dict::validate_spec_str("tables: {}\n", "buffer.yaml"); + assert!(problems.status().failed()); + let schema_error = problems + .items + .iter() + .find(|p| p.code.is_some()) + .expect("expected a coded schema problem"); + assert!(schema_error.location(&problems.source).is_some()); +} From 4d188efe8156452040b56d7dc463d017ebda653a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Sat, 4 Jul 2026 12:42:08 +0200 Subject: [PATCH 2/2] Add VS Code extension backed by a language server Re-add the editor tooling on top of the current spec/meta/data validation API. A new `data-dict-lsp` crate wraps the core validator in a `tower-lsp` server: it publishes live spec squiggles from `validate_spec_str` as you type, and a `data-dict.validateData` command compares each table against its parquet `source` via `validate_data`. Every failure is already a `Problem` carrying its severity, code, and 0-based location, so mapping to LSP diagnostics is a single path. The CLI gains a hidden `lsp` subcommand (behind an `lsp` feature) that serves over stdio; the VS Code extension in `editors/vscode` spawns `data-dict lsp` and speaks standard LSP. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 342 ++++++++++++++++++++++- Cargo.toml | 8 +- crates/data-dict-cli/Cargo.toml | 5 + crates/data-dict-cli/src/main.rs | 17 ++ crates/data-dict-lsp/Cargo.toml | 13 + crates/data-dict-lsp/src/lib.rs | 419 +++++++++++++++++++++++++++++ editors/vscode/.gitignore | 3 + editors/vscode/.vscode/launch.json | 13 + editors/vscode/.vscodeignore | 6 + editors/vscode/README.md | 46 ++++ editors/vscode/package-lock.json | 140 ++++++++++ editors/vscode/package.json | 68 +++++ editors/vscode/src/extension.ts | 172 ++++++++++++ editors/vscode/tsconfig.json | 15 ++ 14 files changed, 1263 insertions(+), 4 deletions(-) create mode 100644 crates/data-dict-lsp/Cargo.toml create mode 100644 crates/data-dict-lsp/src/lib.rs create mode 100644 editors/vscode/.gitignore create mode 100644 editors/vscode/.vscode/launch.json create mode 100644 editors/vscode/.vscodeignore create mode 100644 editors/vscode/README.md create mode 100644 editors/vscode/package-lock.json create mode 100644 editors/vscode/package.json create mode 100644 editors/vscode/src/extension.ts create mode 100644 editors/vscode/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index cabb665..250da3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,12 +138,40 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -316,6 +344,19 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-dict" version = "0.0.1" @@ -339,11 +380,22 @@ version = "0.0.1" dependencies = [ "clap", "data-dict", + "data-dict-lsp", "data-dict-parquet", "insta", "serde_json", ] +[[package]] +name = "data-dict-lsp" +version = "0.0.1" +dependencies = [ + "data-dict", + "serde_json", + "tokio", + "tower-lsp", +] + [[package]] name = "data-dict-parquet" version = "0.0.1" @@ -436,12 +488,59 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -454,8 +553,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -508,6 +612,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -547,6 +657,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -782,12 +898,34 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "lz4_flex" version = "0.11.6" @@ -813,6 +951,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "num" version = "0.4.3" @@ -906,6 +1055,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "parquet" version = "54.3.1" @@ -942,6 +1104,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1057,6 +1239,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "regex" version = "1.12.3" @@ -1092,7 +1283,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1105,6 +1296,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1160,6 +1357,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1199,6 +1407,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1302,6 +1520,123 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -1346,6 +1681,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1463,7 +1799,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -1601,7 +1937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 88bed55..0f9c7e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["crates/data-dict", "crates/data-dict-cli", "crates/data-dict-parquet"] +members = [ + "crates/data-dict", + "crates/data-dict-cli", + "crates/data-dict-lsp", + "crates/data-dict-parquet", +] [workspace.package] version = "0.0.1" @@ -10,6 +15,7 @@ repository = "https://github.com/hadley/data-dict.yaml" [workspace.dependencies] data-dict = { path = "crates/data-dict", version = "0.0.1" } +data-dict-lsp = { path = "crates/data-dict-lsp", version = "0.0.1" } clap = { version = "4", features = ["derive"] } quarto-yaml = "0.1.0" quarto-yaml-validation = "0.1.0" diff --git a/crates/data-dict-cli/Cargo.toml b/crates/data-dict-cli/Cargo.toml index e807273..deca8c0 100644 --- a/crates/data-dict-cli/Cargo.toml +++ b/crates/data-dict-cli/Cargo.toml @@ -10,9 +10,14 @@ repository.workspace = true name = "data-dict" path = "src/main.rs" +[features] +# Bundles the language server in, behind a flag so a plain install stays lean. +lsp = ["dep:data-dict-lsp"] + [dependencies] data-dict.workspace = true data-dict-parquet.workspace = true +data-dict-lsp = { workspace = true, optional = true } clap.workspace = true serde_json.workspace = true diff --git a/crates/data-dict-cli/src/main.rs b/crates/data-dict-cli/src/main.rs index 935c6c8..b46697a 100644 --- a/crates/data-dict-cli/src/main.rs +++ b/crates/data-dict-cli/src/main.rs @@ -31,6 +31,10 @@ enum Command { #[command(subcommand)] command: SkillCommand, }, + /// Run the language server over stdio (used by editor extensions). + #[cfg(feature = "lsp")] + #[command(hide = true)] + Lsp, } /// Shared arguments for `validate-meta` and `validate-data`. @@ -114,6 +118,14 @@ fn main() -> ExitCode { print!("{skill}"); ExitCode::SUCCESS } + #[cfg(feature = "lsp")] + Command::Lsp => match data_dict_lsp::run_stdio() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + ExitCode::FAILURE + } + }, } } @@ -140,6 +152,11 @@ fn subcommands_listing() -> String { fn collect_subcommands(cmd: &clap::Command, prefix: &str, rows: &mut Vec<(String, String)>) { for sub in cmd.get_subcommands() { + // Hidden subcommands (e.g. `lsp`) are excluded from `--help`; keep them + // out of this listing too. + if sub.is_hide_set() { + continue; + } let is_help = sub.get_name() == "help"; // Keep only the top-level `help`; nested `help` entries are noise. if is_help && !prefix.is_empty() { diff --git a/crates/data-dict-lsp/Cargo.toml b/crates/data-dict-lsp/Cargo.toml new file mode 100644 index 0000000..1276015 --- /dev/null +++ b/crates/data-dict-lsp/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "data-dict-lsp" +description = "Language server for data-dict.yaml authoring" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +data-dict.workspace = true +tower-lsp = "0.20" +tokio = { version = "1", features = ["rt-multi-thread", "io-std", "net", "time"] } +serde_json.workspace = true diff --git a/crates/data-dict-lsp/src/lib.rs b/crates/data-dict-lsp/src/lib.rs new file mode 100644 index 0000000..541ee87 --- /dev/null +++ b/crates/data-dict-lsp/src/lib.rs @@ -0,0 +1,419 @@ +//! Language server for `data-dict.yaml` authoring, so editors can show live +//! diagnostics as a dictionary is written. +//! +//! Every failure reaches us as a [`data_dict::Problem`] already carrying its +//! severity, code, and resolved [`location`](data_dict::Problem::location); this +//! module just maps each one to an LSP diagnostic, keeping the core free of LSP +//! types. + +use std::collections::HashMap; + +use data_dict::{Level, Problem, Severity, SourceContext}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +/// The source label attached to every diagnostic this server emits. +const SOURCE: &str = "data-dict"; + +/// The `workspace/executeCommand` that validates a dictionary against the data +/// at each table's `source`. +const VALIDATE_DATA: &str = "data-dict.validateData"; + +struct Backend { + client: Client, + /// Last-seen text of each open document, so a save with no inlined text can + /// still be re-validated. + documents: Mutex>, +} + +#[tower_lsp::async_trait] +impl LanguageServer for Backend { + async fn initialize(&self, _: InitializeParams) -> Result { + Ok(InitializeResult { + server_info: Some(ServerInfo { + name: SOURCE.to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + execute_command_provider: Some(ExecuteCommandOptions { + commands: vec![VALIDATE_DATA.to_string()], + ..Default::default() + }), + ..Default::default() + }, + }) + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { + if params.command != VALIDATE_DATA { + return Ok(None); + } + let Some(uri) = params + .arguments + .first() + .and_then(Value::as_str) + .and_then(|s| Url::parse(s).ok()) + else { + return Ok(None); + }; + let result = tokio::task::spawn_blocking(move || validate_data_command(&uri)) + .await + .unwrap_or_else(|_| json!({ "summary": "internal error", "diagnostics": [] })); + Ok(Some(result)) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let doc = params.text_document; + self.documents + .lock() + .await + .insert(doc.uri.clone(), doc.text.clone()); + self.validate_and_publish(doc.uri, doc.text, Some(doc.version)) + .await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + // Full-sync: the final change event carries the entire document. + let Some(change) = params.content_changes.into_iter().next_back() else { + return; + }; + let uri = params.text_document.uri; + self.documents + .lock() + .await + .insert(uri.clone(), change.text.clone()); + self.validate_and_publish(uri, change.text, Some(params.text_document.version)) + .await; + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + let uri = params.text_document.uri; + let text = match params.text { + Some(text) => text, + None => self + .documents + .lock() + .await + .get(&uri) + .cloned() + .unwrap_or_default(), + }; + self.validate_and_publish(uri, text, None).await; + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + self.documents.lock().await.remove(&uri); + // Clear any squiggles we published for the now-closed document. + self.client.publish_diagnostics(uri, Vec::new(), None).await; + } +} + +impl Backend { + async fn validate_and_publish(&self, uri: Url, text: String, version: Option) { + let target = uri.clone(); + // Validation is synchronous and CPU-bound; keep it off the LSP event loop. + let diagnostics = tokio::task::spawn_blocking(move || document_diagnostics(&text, &target)) + .await + .unwrap_or_default(); + self.client + .publish_diagnostics(uri, diagnostics, version) + .await; + } +} + +/// Spec-validate the buffer `content` (no I/O, so it works unsaved) and return +/// its problems as LSP diagnostics. Data comparison is the separate, on-demand +/// [`validate_data_command`]. +pub fn document_diagnostics(content: &str, uri: &Url) -> Vec { + let problems = data_dict::validate_spec_str(content, uri.as_str()); + let source = &problems.source; + problems + .items + .iter() + .map(|p| problem_to_lsp(p, source)) + .collect() +} + +fn problem_to_lsp(problem: &Problem, source: &SourceContext) -> Diagnostic { + Diagnostic { + range: problem_range(problem, source), + severity: Some(lsp_severity(problem.severity)), + code: problem.code.map(|c| NumberOrString::String(c.to_string())), + source: Some(SOURCE.to_string()), + message: problem_message(problem), + ..Default::default() + } +} + +/// A problem's resolved location as an LSP range (both are 0-based). Unlocated +/// problems collapse to the top of the document rather than being dropped. +fn problem_range(problem: &Problem, source: &SourceContext) -> Range { + match problem.location(source) { + Some(loc) => Range { + start: Position { + line: loc.start_line as u32, + character: loc.start_column as u32, + }, + end: Position { + line: loc.end_line as u32, + character: loc.end_column as u32, + }, + }, + None => Range::default(), + } +} + +/// The rule (`expected`), the specifics (`message`), and any `hint`, one per line. +fn problem_message(problem: &Problem) -> String { + let mut lines = Vec::new(); + if let Some(expected) = &problem.expected { + lines.push(expected.clone()); + } + lines.push(problem.message.clone()); + if let Some(hint) = &problem.hint { + lines.push(hint.clone()); + } + lines.join("\n") +} + +fn lsp_severity(severity: Severity) -> DiagnosticSeverity { + match severity { + Severity::Error => DiagnosticSeverity::ERROR, + Severity::Warning => DiagnosticSeverity::WARNING, + } +} + +/// Validate the dictionary at `uri` against each table's parquet `source`, +/// returning `{ summary, diagnostics }`. Reads from disk, so `uri` must be a +/// saved file. +pub fn validate_data_command(uri: &Url) -> Value { + let Ok(dict_path) = uri.to_file_path() else { + return json!({ "summary": "Data validation needs a file on disk.", "diagnostics": [] }); + }; + + let problems = data_dict::validate_data(&dict_path, None); + + // A spec/pre-flight error means the dictionary must be fixed first. Spec + // warnings don't block, and every spec problem already shows as a live + // squiggle, so we surface only the data-facing (meta/data) problems. + let is_data_facing = |p: &Problem| matches!(p.kind.level(), Some(Level::Meta | Level::Data)); + let dictionary_broken = problems + .items + .iter() + .any(|p| p.severity == Severity::Error && !is_data_facing(p)); + if dictionary_broken { + return json!({ + "summary": "Fix the dictionary's errors before validating data.", + "diagnostics": [], + }); + } + + let source = &problems.source; + let diagnostics: Vec = problems + .items + .iter() + .filter(|p| is_data_facing(p)) + .map(|p| data_diagnostic(p, source)) + .collect(); + + let summary = if diagnostics.is_empty() { + "Data matches the dictionary.".to_string() + } else { + format!( + "{} issue(s) found comparing data to the dictionary.", + diagnostics.len() + ) + }; + json!({ "summary": summary, "diagnostics": diagnostics }) +} + +/// Build the JSON diagnostic payload the extension expects for a data problem. +/// `severity` follows the LSP numbering (`1` = error, `2` = warning). +fn data_diagnostic(problem: &Problem, source: &SourceContext) -> Value { + let range = problem_range(problem, source); + let severity = match problem.severity { + Severity::Error => 1, + Severity::Warning => 2, + }; + json!({ + "range": { + "start": { "line": range.start.line, "character": range.start.character }, + "end": { "line": range.end.line, "character": range.end.character }, + }, + "severity": severity, + "code": problem.code, + "message": problem_message(problem), + }) +} + +/// Run the language server over stdio until the client disconnects. Builds its +/// own Tokio runtime so callers (e.g. the CLI) can stay synchronous. +pub fn run_stdio() -> std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + let (service, socket) = LspService::new(|client| Backend { + client, + documents: Mutex::new(HashMap::new()), + }); + Server::new(stdin, stdout, socket).serve(service).await; + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn diagnose(content: &str) -> Vec { + let uri = Url::parse("file:///buffer.yaml").unwrap(); + document_diagnostics(content, &uri) + } + + #[test] + fn valid_document_has_no_error_diagnostics() { + // A single-table dictionary still draws an S16 warning, so assert only + // that a valid document produces no errors. + let content = "\ +$version: 0.1.0 +$learn_more: http://data-dict.tidyverse.org/ +tables: + - name: t + description: A table. + source: + parquet: t.parquet + columns: + - name: c + type: string + examples: [a, b] + description: A column. +"; + assert!( + diagnose(content) + .iter() + .all(|d| d.severity != Some(DiagnosticSeverity::ERROR)) + ); + } + + #[test] + fn schema_failure_becomes_error_diagnostic() { + // Missing the required `$version` key. + let diagnostics = diagnose("tables: {}\n"); + assert!(!diagnostics.is_empty()); + let d = &diagnostics[0]; + assert_eq!(d.severity, Some(DiagnosticSeverity::ERROR)); + assert!(matches!(&d.code, Some(NumberOrString::String(_)))); + assert_eq!(d.source.as_deref(), Some(SOURCE)); + } + + #[test] + fn spec_failure_carries_code_and_range() { + // An `enum` column must declare a representation; omitting it is S07. + let content = "\ +$version: 0.1.0 +$learn_more: http://data-dict.tidyverse.org/ +tables: + - name: t + description: A table. + source: + parquet: t.parquet + columns: + - name: c + type: enum + description: A column. +"; + let diagnostics = diagnose(content); + let s07 = diagnostics + .iter() + .find(|d| matches!(&d.code, Some(NumberOrString::String(c)) if c == "S07")) + .expect("expected an S07 diagnostic"); + assert_eq!(s07.severity, Some(DiagnosticSeverity::ERROR)); + // The span should point past the document header, not collapse to (0,0). + assert!(s07.range.start.line > 0); + } + + #[test] + fn unparseable_buffer_is_flagged() { + // A tab in indentation is invalid YAML. + let diagnostics = diagnose("foo:\n\t- bar\n"); + assert!(!diagnostics.is_empty()); + assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR)); + } + + fn temp_dir() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let mut dir = std::env::temp_dir(); + dir.push(format!( + "data-dict-lsp-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn validate_data_reports_unreadable_source() { + let dir = temp_dir(); + let dict = dir.join("data-dict.yaml"); + std::fs::write( + &dict, + "\ +$version: 0.1.0 +$learn_more: http://data-dict.tidyverse.org/ +tables: + - name: t + description: A table. + source: + parquet: missing.parquet + columns: + - name: c + type: string + examples: [a, b] + description: A column. +", + ) + .unwrap(); + + let uri = Url::from_file_path(&dict).unwrap(); + let result = validate_data_command(&uri); + let diags = result["diagnostics"].as_array().unwrap(); + assert_eq!(diags.len(), 1); + // M05: the table's `source` points at a file that can't be read. + assert_eq!(diags[0]["code"], "M05"); + assert_eq!(diags[0]["severity"], 1); + } + + #[test] + fn validate_data_refuses_invalid_dictionary() { + // Missing `$version`: the dictionary itself is invalid, so data + // validation should decline rather than report data diagnostics. + let dir = temp_dir(); + let dict = dir.join("data-dict.yaml"); + std::fs::write(&dict, "tables: {}\n").unwrap(); + let uri = Url::from_file_path(&dict).unwrap(); + let result = validate_data_command(&uri); + assert!(result["diagnostics"].as_array().unwrap().is_empty()); + assert!( + result["summary"] + .as_str() + .unwrap() + .contains("Fix the dictionary"), + ); + } +} diff --git a/editors/vscode/.gitignore b/editors/vscode/.gitignore new file mode 100644 index 0000000..d3e15b1 --- /dev/null +++ b/editors/vscode/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +out/ +*.vsix diff --git a/editors/vscode/.vscode/launch.json b/editors/vscode/.vscode/launch.json new file mode 100644 index 0000000..a142310 --- /dev/null +++ b/editors/vscode/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: compile" + } + ] +} diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore new file mode 100644 index 0000000..f9ab8e5 --- /dev/null +++ b/editors/vscode/.vscodeignore @@ -0,0 +1,6 @@ +.vscode/** +src/** +**/*.ts +**/*.map +tsconfig.json +.gitignore diff --git a/editors/vscode/README.md b/editors/vscode/README.md new file mode 100644 index 0000000..8fc5752 --- /dev/null +++ b/editors/vscode/README.md @@ -0,0 +1,46 @@ +# data-dict.yaml for VS Code + +Live validation for [`data-dict.yaml`](https://data-dict.tidyverse.org/) data +dictionaries. The extension is a thin client: it launches the `data-dict` +binary as a language server (`data-dict lsp`) and shows schema and `DD0xx` lint +findings as you type. + +## Prerequisites + +Build the binary with the language-server feature: + +```bash +cargo build -p data-dict-cli --features lsp +``` + +When `dataDict.server.path` is left at its default, the extension looks for a +`target/release` or `target/debug` build — first in the opened workspace, then +relative to the extension itself (so running from source via F5 finds +this repo's build regardless of which project you open) — before falling back to +`data-dict` on your `PATH`. Set the path explicitly (an absolute path is most +reliable) if your binary lives elsewhere. + +## Try it (development) + +1. From `editors/vscode/`, run `npm install`. +2. Open `editors/vscode/` in VS Code and press F5 to launch the + Extension Development Host (this compiles via the `npm: compile` task first). +3. In the new window, open a `data-dict.yaml` file. Introduce an error — for + example delete the `$version` key, or give an `enum` column no `values` — and + a squiggle appears. + +By default only files named `data-dict.yaml` are checked. To validate other +files (e.g. the examples under `site/examples/`), add a glob to +`dataDict.files`, such as `**/*.yaml`. + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `dataDict.server.path` | `data-dict` | Path to the `data-dict` executable (built with the `lsp` feature). | +| `dataDict.files` | `["**/data-dict.yaml"]` | Glob patterns for files validated as data dictionaries. | + +## Commands + +- **data-dict: Restart language server** — restart the server after rebuilding + the binary. diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json new file mode 100644 index 0000000..b62e323 --- /dev/null +++ b/editors/vscode/package-lock.json @@ -0,0 +1,140 @@ +{ + "name": "data-dict", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "data-dict", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.4.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + } + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 0000000..f304995 --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,68 @@ +{ + "name": "data-dict", + "displayName": "data-dict.yaml", + "description": "Live validation for data-dict.yaml data dictionaries.", + "version": "0.0.1", + "publisher": "data-dict", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/hadley/data-dict.yaml" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Linters", + "Programming Languages" + ], + "activationEvents": [ + "onLanguage:yaml" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "dataDict.validateData", + "title": "data-dict: Validate data against dictionary" + }, + { + "command": "dataDict.restartServer", + "title": "data-dict: Restart language server" + } + ], + "configuration": { + "title": "data-dict.yaml", + "properties": { + "dataDict.server.path": { + "type": "string", + "default": "data-dict", + "markdownDescription": "Path to the `data-dict` executable, which must be built with the `lsp` feature (`cargo build -p data-dict-cli --features lsp`). Defaults to `data-dict` on your `PATH`; when left at the default, a `target/release` or `target/debug` build in the workspace is used if present." + }, + "dataDict.files": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/data-dict.yaml" + ], + "markdownDescription": "Glob patterns for files validated as data dictionaries. Add e.g. `**/*.yaml` to validate every YAML file." + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.4.0" + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts new file mode 100644 index 0000000..057d6a3 --- /dev/null +++ b/editors/vscode/src/extension.ts @@ -0,0 +1,172 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + ExecuteCommandRequest, + LanguageClient, + LanguageClientOptions, + ServerOptions, +} from "vscode-languageclient/node"; + +let client: LanguageClient | undefined; +let dataDiagnostics: vscode.DiagnosticCollection | undefined; + +export async function activate( + context: vscode.ExtensionContext, +): Promise { + dataDiagnostics = vscode.languages.createDiagnosticCollection("data-dict (data)"); + context.subscriptions.push( + dataDiagnostics, + vscode.commands.registerCommand("dataDict.validateData", () => validateData()), + vscode.commands.registerCommand("dataDict.restartServer", () => + restart(context), + ), + // Data diagnostics are a point-in-time snapshot; drop them once the + // dictionary is edited so stale results don't linger. + vscode.workspace.onDidChangeTextDocument((e) => + dataDiagnostics?.delete(e.document.uri), + ), + ); + await start(context); +} + +export async function deactivate(): Promise { + await client?.stop(); + client = undefined; +} + +interface DataDiagnostic { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + severity: number; + code?: string; + message: string; +} + +async function validateData(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + void vscode.window.showWarningMessage( + "data-dict: open a data dictionary to validate.", + ); + return; + } + if (!client) { + void vscode.window.showWarningMessage( + "data-dict: the language server is not running.", + ); + return; + } + + const uri = editor.document.uri; + try { + const result = (await client.sendRequest(ExecuteCommandRequest.type, { + command: "data-dict.validateData", + arguments: [uri.toString()], + })) as { summary?: string; diagnostics?: DataDiagnostic[] } | null; + + const diagnostics = (result?.diagnostics ?? []).map(toDiagnostic); + dataDiagnostics?.set(uri, diagnostics); + if (result?.summary) { + void vscode.window.showInformationMessage(`data-dict: ${result.summary}`); + } + } catch (err) { + void vscode.window.showErrorMessage( + `data-dict: data validation failed. ${String(err)}`, + ); + } +} + +function toDiagnostic(d: DataDiagnostic): vscode.Diagnostic { + const range = new vscode.Range( + d.range.start.line, + d.range.start.character, + d.range.end.line, + d.range.end.character, + ); + const severity = + d.severity === 2 + ? vscode.DiagnosticSeverity.Warning + : vscode.DiagnosticSeverity.Error; + const diagnostic = new vscode.Diagnostic(range, d.message, severity); + diagnostic.source = "data-dict (data)"; + if (d.code !== undefined) { + diagnostic.code = d.code; + } + return diagnostic; +} + +async function start(context: vscode.ExtensionContext): Promise { + const config = vscode.workspace.getConfiguration("dataDict"); + const command = resolveServerPath( + config.get("server.path") ?? "data-dict", + context.extensionPath, + ); + const globs = config.get("files") ?? ["**/data-dict.yaml"]; + + // One executable acts as both CLI and language server; `lsp` selects the + // server, speaking LSP over stdio. + const serverOptions: ServerOptions = { command, args: ["lsp"] }; + + const clientOptions: LanguageClientOptions = { + documentSelector: globs.map((pattern) => ({ scheme: "file", pattern })), + }; + + client = new LanguageClient( + "dataDict", + "data-dict.yaml", + serverOptions, + clientOptions, + ); + + try { + await client.start(); + } catch (err) { + void vscode.window.showErrorMessage( + `data-dict language server failed to start (\`${command} lsp\`). ` + + "Build it with `cargo build -p data-dict-cli --features lsp`, or set " + + `\`dataDict.server.path\`. ${String(err)}`, + ); + } +} + +async function restart(context: vscode.ExtensionContext): Promise { + await client?.stop(); + client = undefined; + await start(context); +} + +/// Resolve the server executable. An explicit, non-default setting wins (taken +/// relative to the workspace when not absolute). Otherwise look for a binary +/// built under `target/` — first in the opened workspace, then relative to the +/// extension itself (so running from source via F5 finds this repo's build no +/// matter which project is open) — before falling back to `data-dict` on `PATH`. +function resolveServerPath(configured: string, extensionPath: string): string { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (configured && configured !== "data-dict") { + if (path.isAbsolute(configured)) { + return configured; + } + return folder ? path.join(folder, configured) : configured; + } + + // `editors/vscode/` is two levels below the workspace root, where `target/` + // lives. Check the opened folder first, then the extension's own location. + const roots = [folder, path.join(extensionPath, "..", "..")].filter( + (root): root is string => Boolean(root), + ); + const exe = process.platform === "win32" ? "data-dict.exe" : "data-dict"; + for (const root of roots) { + for (const profile of ["release", "debug"]) { + const candidate = path.join(root, "target", profile, exe); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + + return "data-dict"; +} diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json new file mode 100644 index 0000000..7bc9386 --- /dev/null +++ b/editors/vscode/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2021", + "lib": ["ES2021"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", ".vscode-test"] +}