Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions crates/css-var-kit/src/commands/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,10 +65,7 @@ pub fn run(cwd: &Path, log: bool) -> Result<(), Box<dyn Error>> {
let mut init_params: InitializeParams =
serde_json::from_value(connection.initialize(capabilities_json)?)?;

let init_options: Option<RawConfig> = 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
Expand All @@ -77,7 +74,7 @@ pub fn run(cwd: &Path, log: bool) -> Result<(), Box<dyn Error>> {
.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())?,
Expand Down Expand Up @@ -148,7 +145,7 @@ struct Server<'a> {
connection: &'a Connection,
config: Config,
lsp_root_dir: PathBuf,
init_options: Option<RawConfig>,
init_options: Option<serde_json::Value>,
client_name: Option<String>,
opened_documents: HashMap<Uri, String>,
source_cache: HashMap<Rc<Path>, OwnedStr>,
Expand Down Expand Up @@ -371,7 +368,7 @@ impl Server<'_> {
}

fn reload_config(&mut self) -> Result<(), Box<dyn Error>> {
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)?;
Expand Down
35 changes: 23 additions & 12 deletions crates/css-var-kit/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
pub mod file;
pub mod rules;

pub use file::RawConfig;

use std::path::{Path, PathBuf};

use globset::{Glob, GlobMatcher};
Expand All @@ -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,
Expand Down Expand Up @@ -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<file::RawConfig>,
init_options: Option<&serde_json::Value>,
) -> Result<Self, ConfigError> {
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)
}
Expand All @@ -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<Self, ConfigError> {
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 {
Expand Down
15 changes: 11 additions & 4 deletions crates/css-var-kit/src/config/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IgnoredAny>,
#[serde(default = "default_root_dir")]
pub root_dir: String,
#[serde(default = "default_lookup_files")]
Expand All @@ -31,14 +33,15 @@ 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<String>,
}

impl Default for RawConfig {
fn default() -> Self {
Self {
_schema: None,
root_dir: default_root_dir(),
lookup_files: default_lookup_files(),
definition_files: None,
Expand Down Expand Up @@ -72,6 +75,10 @@ impl RawConfig {
.transpose()
}

pub fn from_init_options(value: &serde_json::Value) -> Result<Self, ConfigError> {
Self::deserialize(value).map_err(|e| ConfigError::InitOptions { source: e })
}

pub(super) fn load_from(path: &Path) -> Result<Self, ConfigError> {
let raw = fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
Expand Down Expand Up @@ -107,7 +114,7 @@ pub(super) fn parse_jsonc<T: DeserializeOwned>(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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions crates/css-var-kit/tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
56 changes: 56 additions & 0 deletions crates/css-var-kit/tests/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
17 changes: 14 additions & 3 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions packages/vscode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ 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;
}

const CONFIG_KEYS = [
"rootDir",
"lookupFiles",
"excludeFiles",
"definitionFiles",
"include",
"rules.noUndefinedVariableUse",
"rules.noVariableTypeMismatch",
"rules.noInconsistentVariableDefinition",
Expand Down Expand Up @@ -57,8 +59,13 @@ export function buildInitializationOptions(): InitializationOptions | undefined
hasAny = true;
}

if (isExplicitlySet(config, "excludeFiles")) {
opts.excludeFiles = config.get<string[]>("excludeFiles");
if (isExplicitlySet(config, "definitionFiles")) {
opts.definitionFiles = config.get<string[]>("definitionFiles");
hasAny = true;
}

if (isExplicitlySet(config, "include")) {
opts.include = config.get<string[]>("include");
hasAny = true;
}

Expand Down
Loading