Specify a schema to use when dealing with JSONC files (#44250)

Kirill Bulatov created

Follow-up of https://github.com/zed-industries/zed/pull/43854
Closes https://github.com/zed-industries/zed/issues/40970

Seems that json language server does not distinguish between JSONC and
JSON files in runtime, but there is a static schema, which accepts globs
in its `fileMatch` fields.

Use all glob overrides and file suffixes for JSONC inside those match
fields, and provide a grammar for such matches, which accepts trailing
commas.

Release Notes:

- Improved JSONC trailing comma handling

Change summary

crates/json_schema_store/src/json_schema_store.rs | 54 +++++++++++++++-
crates/language/src/language_registry.rs          |  4 
crates/language/src/language_settings.rs          |  9 +
crates/languages/src/json.rs                      | 11 +-
crates/languages/src/lib.rs                       |  2 
5 files changed, 65 insertions(+), 15 deletions(-)

Detailed changes

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -3,8 +3,9 @@ use std::{str::FromStr, sync::Arc};
 
 use anyhow::{Context as _, Result};
 use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, WeakEntity};
-use language::LanguageRegistry;
+use language::{LanguageRegistry, language_settings::all_language_settings};
 use project::LspStore;
+use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
 
 // Origin: https://github.com/SchemaStore/schemastore
 const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json");
@@ -159,14 +160,35 @@ pub fn resolve_schema_request_inner(
             }
         }
         "snippets" => snippet_provider::format::VsSnippetsFile::generate_json_schema(),
+        "jsonc" => jsonc_schema(),
         _ => {
-            anyhow::bail!("Unrecognized builtin JSON schema: {}", schema_name);
+            anyhow::bail!("Unrecognized builtin JSON schema: {schema_name}");
         }
     };
     Ok(schema)
 }
 
-pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value {
+const JSONC_LANGUAGE_NAME: &str = "JSONC";
+
+pub fn all_schema_file_associations(
+    languages: &Arc<LanguageRegistry>,
+    cx: &mut App,
+) -> serde_json::Value {
+    let extension_globs = languages
+        .available_language_for_name(JSONC_LANGUAGE_NAME)
+        .map(|language| language.matcher().path_suffixes.clone())
+        .into_iter()
+        .flatten()
+        // Path suffixes can be entire file names or just their extensions.
+        .flat_map(|path_suffix| [format!("*.{path_suffix}"), path_suffix]);
+    let override_globs = all_language_settings(None, cx)
+        .file_types
+        .get(JSONC_LANGUAGE_NAME)
+        .into_iter()
+        .flat_map(|(_, glob_strings)| glob_strings)
+        .cloned();
+    let jsonc_globs = extension_globs.chain(override_globs).collect::<Vec<_>>();
+
     let mut file_associations = serde_json::json!([
         {
             "fileMatch": [
@@ -211,6 +233,10 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value {
             "fileMatch": ["package.json"],
             "url": "zed://schemas/package_json"
         },
+        {
+            "fileMatch": &jsonc_globs,
+            "url": "zed://schemas/jsonc"
+        },
     ]);
 
     #[cfg(debug_assertions)]
@@ -233,7 +259,7 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value {
             let file_name = normalized_action_name_to_file_name(normalized_name.clone());
             serde_json::json!({
                 "fileMatch": [file_name],
-                "url": format!("zed://schemas/action/{}", normalized_name)
+                "url": format!("zed://schemas/action/{normalized_name}")
             })
         }),
     );
@@ -249,6 +275,26 @@ fn package_json_schema() -> serde_json::Value {
     serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap()
 }
 
+fn jsonc_schema() -> serde_json::Value {
+    let generator = schemars::generate::SchemaSettings::draft2019_09()
+        .with_transform(DefaultDenyUnknownFields)
+        .with_transform(AllowTrailingCommas)
+        .into_generator();
+    let meta_schema = generator
+        .settings()
+        .meta_schema
+        .as_ref()
+        .expect("meta_schema should be present in schemars settings")
+        .to_string();
+    let defs = generator.definitions();
+    let schema = schemars::json_schema!({
+        "$schema": meta_schema,
+        "allowTrailingCommas": true,
+        "$defs": defs,
+    });
+    serde_json::to_value(schema).unwrap()
+}
+
 fn generate_inspector_style_schema() -> serde_json::Value {
     let schema = schemars::generate::SchemaSettings::draft2019_09()
         .with_transform(util::schemars::DefaultDenyUnknownFields)

crates/language/src/language_registry.rs 🔗

@@ -745,7 +745,7 @@ impl LanguageRegistry {
         self: &Arc<Self>,
         path: &Path,
         content: Option<&Rope>,
-        user_file_types: Option<&FxHashMap<Arc<str>, GlobSet>>,
+        user_file_types: Option<&FxHashMap<Arc<str>, (GlobSet, Vec<String>)>>,
     ) -> Option<AvailableLanguage> {
         let filename = path.file_name().and_then(|filename| filename.to_str());
         // `Path.extension()` returns None for files with a leading '.'
@@ -788,7 +788,7 @@ impl LanguageRegistry {
             let path_matches_custom_suffix = || {
                 user_file_types
                     .and_then(|types| types.get(language_name.as_ref()))
-                    .map_or(None, |custom_suffixes| {
+                    .map_or(None, |(custom_suffixes, _)| {
                         path_suffixes
                             .iter()
                             .find(|(_, candidate)| custom_suffixes.is_match_candidate(candidate))

crates/language/src/language_settings.rs 🔗

@@ -51,7 +51,7 @@ pub struct AllLanguageSettings {
     pub edit_predictions: EditPredictionSettings,
     pub defaults: LanguageSettings,
     languages: HashMap<LanguageName, LanguageSettings>,
-    pub(crate) file_types: FxHashMap<Arc<str>, GlobSet>,
+    pub file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)>,
 }
 
 #[derive(Debug, Clone, PartialEq)]
@@ -656,7 +656,7 @@ impl settings::Settings for AllLanguageSettings {
 
         let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap();
 
-        let mut file_types: FxHashMap<Arc<str>, GlobSet> = FxHashMap::default();
+        let mut file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)> = FxHashMap::default();
 
         for (language, patterns) in all_languages.file_types.iter().flatten() {
             let mut builder = GlobSetBuilder::new();
@@ -665,7 +665,10 @@ impl settings::Settings for AllLanguageSettings {
                 builder.add(Glob::new(pattern).unwrap());
             }
 
-            file_types.insert(language.clone(), builder.build().unwrap());
+            file_types.insert(
+                language.clone(),
+                (builder.build().unwrap(), patterns.0.clone()),
+            );
         }
 
         Self {

crates/languages/src/json.rs 🔗

@@ -7,8 +7,8 @@ use futures::StreamExt;
 use gpui::{App, AsyncApp, Task};
 use http_client::github::{GitHubLspBinaryVersion, latest_github_release};
 use language::{
-    ContextProvider, LanguageName, LocalFile as _, LspAdapter, LspAdapterDelegate, LspInstaller,
-    Toolchain,
+    ContextProvider, LanguageName, LanguageRegistry, LocalFile as _, LspAdapter,
+    LspAdapterDelegate, LspInstaller, Toolchain,
 };
 use lsp::{LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
@@ -129,14 +129,15 @@ fn server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 }
 
 pub struct JsonLspAdapter {
+    languages: Arc<LanguageRegistry>,
     node: NodeRuntime,
 }
 
 impl JsonLspAdapter {
     const PACKAGE_NAME: &str = "vscode-langservers-extracted";
 
-    pub fn new(node: NodeRuntime) -> Self {
-        Self { node }
+    pub fn new(languages: Arc<LanguageRegistry>, node: NodeRuntime) -> Self {
+        Self { languages, node }
     }
 }
 
@@ -255,7 +256,7 @@ impl LspAdapter for JsonLspAdapter {
         cx: &mut AsyncApp,
     ) -> Result<Value> {
         let mut config = cx.update(|cx| {
-            let schemas = json_schema_store::all_schema_file_associations(cx);
+            let schemas = json_schema_store::all_schema_file_associations(&self.languages, cx);
 
             // This can be viewed via `dev: open language server logs` -> `json-language-server` ->
             // `Server Info`

crates/languages/src/lib.rs 🔗

@@ -89,7 +89,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
     let go_context_provider = Arc::new(go::GoContextProvider);
     let go_lsp_adapter = Arc::new(go::GoLspAdapter);
     let json_context_provider = Arc::new(JsonTaskProvider);
-    let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(node.clone()));
+    let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(languages.clone(), node.clone()));
     let node_version_lsp_adapter = Arc::new(json::NodeVersionAdapter);
     let py_lsp_adapter = Arc::new(python::PyLspAdapter::new());
     let ty_lsp_adapter = Arc::new(python::TyLspAdapter::new(fs.clone()));