diff --git a/crates/css-var-kit/src/commands/lsp.rs b/crates/css-var-kit/src/commands/lsp.rs index b3bb699..27b31b0 100644 --- a/crates/css-var-kit/src/commands/lsp.rs +++ b/crates/css-var-kit/src/commands/lsp.rs @@ -27,7 +27,7 @@ use lsp_types::{ }; use crate::commands::lint; -use crate::config::{Config, ConfigError, RawConfig}; +use crate::config::{Config, ConfigError}; use crate::file_kinds::is_config_filename; use crate::owned_types::OwnedStr; use crate::parser::ParseResult; @@ -65,10 +65,7 @@ pub fn run(cwd: &Path, log: bool) -> Result<(), Box> { let mut init_params: InitializeParams = serde_json::from_value(connection.initialize(capabilities_json)?)?; - let init_options: Option = init_params - .initialization_options - .take() - .and_then(|v| serde_json::from_value(v).ok()); + let init_options = init_params.initialization_options.take(); let root_dir = init_params .workspace_folders @@ -77,7 +74,7 @@ pub fn run(cwd: &Path, log: bool) -> Result<(), Box> { .and_then(|folder| uri_to_path(&folder.uri)) .unwrap_or_else(|| cwd.to_path_buf()); - let (config, config_error) = match Config::load_for_lsp(&root_dir, init_options.clone()) { + let (config, config_error) = match Config::load_for_lsp(&root_dir, init_options.as_ref()) { Ok(config) => (config, None), Err(e) => ( Config::without_rules_for_lsp(&root_dir, init_options.as_ref())?, @@ -148,7 +145,7 @@ struct Server<'a> { connection: &'a Connection, config: Config, lsp_root_dir: PathBuf, - init_options: Option, + init_options: Option, client_name: Option, opened_documents: HashMap, source_cache: HashMap, OwnedStr>, @@ -371,7 +368,7 @@ impl Server<'_> { } fn reload_config(&mut self) -> Result<(), Box> { - self.config = match Config::load_for_lsp(&self.lsp_root_dir, self.init_options.clone()) { + self.config = match Config::load_for_lsp(&self.lsp_root_dir, self.init_options.as_ref()) { Ok(config) => config, Err(e) => { self.report_config_error(&e)?; diff --git a/crates/css-var-kit/src/config.rs b/crates/css-var-kit/src/config.rs index e42f1b6..5729791 100644 --- a/crates/css-var-kit/src/config.rs +++ b/crates/css-var-kit/src/config.rs @@ -1,8 +1,6 @@ pub mod file; pub mod rules; -pub use file::RawConfig; - use std::path::{Path, PathBuf}; use globset::{Glob, GlobMatcher}; @@ -25,6 +23,8 @@ pub enum ConfigError { path: PathBuf, source: serde_json::Error, }, + #[error("invalid initializationOptions: {source}")] + InitOptions { source: serde_json::Error }, #[error("cannot read config file {path}: {source}")] ReadFile { path: PathBuf, @@ -183,17 +183,25 @@ impl Config { /// settings (e.g. `logFile`) work even when a shared `cvk.json` exists. pub fn load_for_lsp( root_dir: &Path, - init_options: Option, + init_options: Option<&serde_json::Value>, ) -> Result { let project_root = find_project_root(root_dir); - let init_lsp_log_file = init_options.as_ref().and_then(|c| c.lsp.log_file.clone()); - let mut raw = file::RawConfig::load(&project_root)? - .or(init_options) - .unwrap_or_default(); - if raw.lsp.log_file.is_none() { - raw.lsp.log_file = init_lsp_log_file; - } + let parsed_init_options = init_options + .map(file::RawConfig::from_init_options) + .transpose(); + let raw = match file::RawConfig::load(&project_root)? { + Some(raw) if raw.lsp.log_file.is_some() => raw, + Some(raw) => file::RawConfig { + lsp: parsed_init_options + .ok() + .flatten() + .map(|c| c.lsp) + .unwrap_or_default(), + ..raw + }, + None => parsed_init_options?.unwrap_or_default(), + }; Self::from_raw_for_lsp(&project_root, raw) } @@ -203,10 +211,13 @@ impl Config { /// do not flood the editor with diagnostics. pub fn without_rules_for_lsp( root_dir: &Path, - init_options: Option<&file::RawConfig>, + init_options: Option<&serde_json::Value>, ) -> Result { let raw = file::RawConfig { - lsp: init_options.map(|c| c.lsp.clone()).unwrap_or_default(), + lsp: init_options + .and_then(|v| file::RawConfig::from_init_options(v).ok()) + .map(|c| c.lsp) + .unwrap_or_default(), ..Default::default() }; Self::from_raw_for_lsp(&find_project_root(root_dir), raw).map(|config| Self { diff --git a/crates/css-var-kit/src/config/file.rs b/crates/css-var-kit/src/config/file.rs index 5be5ad7..f54f580 100644 --- a/crates/css-var-kit/src/config/file.rs +++ b/crates/css-var-kit/src/config/file.rs @@ -3,7 +3,7 @@ use std::io; use std::path::{Path, PathBuf}; use serde::Deserialize; -use serde::de::{self, DeserializeOwned, Deserializer}; +use serde::de::{self, DeserializeOwned, Deserializer, IgnoredAny}; use super::ConfigError; use crate::file_kinds::CONFIG_FILENAMES; @@ -13,8 +13,10 @@ use crate::rules::enforce_variable_use::config::RawEnforceVariableUse; const UTF8_BOM: char = '\u{feff}'; #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RawConfig { + #[serde(rename = "$schema", default)] + pub(super) _schema: Option, #[serde(default = "default_root_dir")] pub root_dir: String, #[serde(default = "default_lookup_files")] @@ -31,7 +33,7 @@ pub struct RawConfig { } #[derive(Debug, Default, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RawLspConfig { pub log_file: Option, } @@ -39,6 +41,7 @@ pub struct RawLspConfig { impl Default for RawConfig { fn default() -> Self { Self { + _schema: None, root_dir: default_root_dir(), lookup_files: default_lookup_files(), definition_files: None, @@ -72,6 +75,10 @@ impl RawConfig { .transpose() } + pub fn from_init_options(value: &serde_json::Value) -> Result { + Self::deserialize(value).map_err(|e| ConfigError::InitOptions { source: e }) + } + pub(super) fn load_from(path: &Path) -> Result { let raw = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile { path: path.to_path_buf(), @@ -107,7 +114,7 @@ pub(super) fn parse_jsonc(mut raw: String) -> serde_json::R } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct RawRules { #[serde(default = "default_error")] pub no_undefined_variable_use: SeverityToggle, diff --git a/crates/css-var-kit/src/rules/enforce_variable_use/config.rs b/crates/css-var-kit/src/rules/enforce_variable_use/config.rs index 504ee02..07c7062 100644 --- a/crates/css-var-kit/src/rules/enforce_variable_use/config.rs +++ b/crates/css-var-kit/src/rules/enforce_variable_use/config.rs @@ -56,7 +56,7 @@ impl RawEnforceVariableUse { } } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RawEnforceVariableUseConfig { #[serde(default = "default_severity")] pub severity: SeverityToggle, diff --git a/crates/css-var-kit/tests/config.rs b/crates/css-var-kit/tests/config.rs index f942fdf..58f62b0 100644 --- a/crates/css-var-kit/tests/config.rs +++ b/crates/css-var-kit/tests/config.rs @@ -73,3 +73,37 @@ fn cvk_json_takes_precedence_over_cvk_jsonc_with_warning() { root.join("cvk.jsonc").display(), ))); } + +#[test] +fn config_with_unknown_field_is_rejected() { + [ + r#"{"excludeFiles": []}"#, + r#"{"lsp": {"logfile": "cvk.log"}}"#, + r#"{"rules": {"no-undefined-variable": "off"}}"#, + r#"{"rules": {"enforce-variable-use": {"allowedFunction": ["calc"]}}}"#, + ] + .iter() + .for_each(|config| { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("cvk.json"), config).unwrap(); + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("cvk"); + cmd.current_dir(tmp.path()); + cmd.arg("lint") + .assert() + .failure() + .stderr(predicates::str::contains("unknown field")); + }); +} + +#[test] +fn config_with_schema_field_is_accepted() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("cvk.json"), + r#"{"$schema": "./node_modules/css-var-kit/cvk.schema.json"}"#, + ) + .unwrap(); + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("cvk"); + cmd.current_dir(tmp.path()); + cmd.arg("lint").assert().success(); +} diff --git a/crates/css-var-kit/tests/lsp.rs b/crates/css-var-kit/tests/lsp.rs index a169605..2ea4a3e 100644 --- a/crates/css-var-kit/tests/lsp.rs +++ b/crates/css-var-kit/tests/lsp.rs @@ -802,6 +802,62 @@ fn initialization_options_ignored_when_config_file_exists() { ); } +#[test] +fn invalid_initialization_options_disable_diagnostics_and_show_error() { + let fixture_dir = Path::new(common::FIXTURES).join("no-config"); + let mut client = LspClient::spawn(&fixture_dir); + + client.initialize_with_options(Some(serde_json::json!({ "rootDir": 1 }))); + + let uri = client.file_uri("components/button.css"); + let text = fs::read_to_string(fixture_dir.join("components/button.css")).unwrap(); + client.open_document(&uri, &text); + + let diagnostics = client.collect_diagnostics(); + client.shutdown(); + + let messages = collect_messages_for(&diagnostics, "components/button.css"); + assert!( + messages.is_empty(), + "invalid initializationOptions should disable diagnostics, got: {messages:?}" + ); + assert!( + client + .shown_messages() + .iter() + .any(|m| m.contains("invalid initializationOptions")), + "expected an initializationOptions error message, got: {:?}", + client.shown_messages() + ); +} + +#[test] +fn invalid_initialization_options_ignored_when_config_file_exists() { + let tmp = copy_fixture_to_tempdir("default"); + + let mut client = LspClient::spawn(tmp.path()); + + client.initialize_with_options(Some(serde_json::json!({ "rootDir": 1 }))); + + let uri = client.file_uri("components/button.css"); + let text = fs::read_to_string(tmp.path().join("components/button.css")).unwrap(); + client.open_document(&uri, &text); + + let diagnostics = client.collect_diagnostics(); + client.shutdown(); + + let messages = collect_messages_for(&diagnostics, "components/button.css"); + assert!( + messages.iter().any(|m| m.contains("--spacing-md")), + "cvk.json should be used regardless of initializationOptions, got: {messages:?}" + ); + assert!( + client.shown_messages().is_empty(), + "expected no error message, got: {:?}", + client.shown_messages() + ); +} + #[test] fn excluded_file_produces_no_diagnostics_on_open() { let tmp = copy_fixture_to_tempdir("default"); diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 59803b1..efebca9 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -33,7 +33,8 @@ If no config file exists, you can configure rules via VS Code settings: | -------------------------------------------- | -------------- | -------------------------------------- | | `cvk.path` | `null` | Path to the `cvk` binary | | `cvk.rootDir` | `"."` | Root directory for analysis | -| `cvk.lookupFiles` | `["**/*.css"]` | Glob patterns for CSS files | +| `cvk.definitionFiles` | `["**/*.css"]` | Files to collect definitions and lint | +| `cvk.include` | `[]` | Definition-only files, `!` to exclude | | `cvk.rules.noUndefinedVariableUse` | `"error"` | Undefined variable usage | | `cvk.rules.noVariableTypeMismatch` | `"error"` | Variable type mismatch | | `cvk.rules.noInconsistentVariableDefinition` | `"error"` | Inconsistent variable definitions | diff --git a/packages/vscode/package.json b/packages/vscode/package.json index e313a8c..b9fba9d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -53,15 +53,26 @@ "default": [ "**/*.css" ], - "description": "Glob patterns for CSS files to include." + "description": "Glob patterns for CSS files to include.", + "markdownDeprecationMessage": "Use `#cvk.definitionFiles#` instead. Ignored when `#cvk.definitionFiles#` is set." }, - "cvk.excludeFiles": { + "cvk.definitionFiles": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/*.css" + ], + "markdownDescription": "Glob patterns for files scanned for CSS variable definitions and linted. Supports `!` negation patterns." + }, + "cvk.include": { "type": "array", "items": { "type": "string" }, "default": [], - "description": "Glob patterns for files to exclude from linting." + "markdownDescription": "Additional glob patterns for definition-only files (not linted). Prefix a pattern with `!` to exclude files from linting and definition collection." }, "cvk.rules.noUndefinedVariableUse": { "type": "string", diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index 25a67c2..d33470c 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -14,7 +14,8 @@ interface RawLspConfig { interface InitializationOptions { rootDir?: string | undefined; lookupFiles?: string[] | undefined; - excludeFiles?: string[] | undefined; + definitionFiles?: string[] | undefined; + include?: string[] | undefined; rules?: RawRules | undefined; lsp?: RawLspConfig | undefined; } @@ -22,7 +23,8 @@ interface InitializationOptions { const CONFIG_KEYS = [ "rootDir", "lookupFiles", - "excludeFiles", + "definitionFiles", + "include", "rules.noUndefinedVariableUse", "rules.noVariableTypeMismatch", "rules.noInconsistentVariableDefinition", @@ -57,8 +59,13 @@ export function buildInitializationOptions(): InitializationOptions | undefined hasAny = true; } - if (isExplicitlySet(config, "excludeFiles")) { - opts.excludeFiles = config.get("excludeFiles"); + if (isExplicitlySet(config, "definitionFiles")) { + opts.definitionFiles = config.get("definitionFiles"); + hasAny = true; + } + + if (isExplicitlySet(config, "include")) { + opts.include = config.get("include"); hasAny = true; }