use ls_types::Uri;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use ls_types::{
CodeAction, CodeActionContext, CodeActionKind, CodeActionOrCommand, CodeActionParams,
CodeActionProviderCapability, CodeActionResponse, ColorInformation, ColorPresentation,
ColorPresentationParams, ColorProviderCapability, CompletionItem, CompletionItemKind,
CompletionOptions, CompletionParams, CompletionResponse, CreateFilesParams, DeleteFilesParams,
Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentColorParams, DocumentSymbol,
DocumentSymbolParams, DocumentSymbolResponse, FileChangeType, GotoDefinitionParams,
GotoDefinitionResponse, Hover, HoverContents, HoverParams, InitializeParams, InitializeResult,
Location, MarkupContent, MarkupKind, MessageType, OneOf, Position, PrepareRenameResponse,
Range, ReferenceParams, RenameFilesParams, RenameOptions, RenameParams, ServerCapabilities,
SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, TextDocumentPositionParams,
TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, WillSaveTextDocumentParams,
WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersServerCapabilities,
WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkspaceSymbolResponse,
};
use regex::Regex;
use tokio::sync::{Mutex, RwLock};
use tower_lsp_server::{Client, LanguageServer};
use crate::color::{generate_color_presentations, parse_color, NormalizedColorKey};
use crate::completion_context::{
completion_value_context_slice, get_value_context_info, score_variable_relevance,
};
#[cfg(test)]
use crate::completion_context::{
find_html_style_attribute_slice, find_html_style_block_slice, find_js_string_segment,
};
use crate::config_analysis::{
config_variable_source, is_supported_config_path, parse_config_document,
parse_config_document_at_version,
};
use crate::document_kind::{
apply_config_patch, build_lookup_extension_map, resolve_document_kind, ClientConfigPatch,
DocumentKind,
};
use crate::manager::CssVariableManager;
use crate::parsers::{parse_css_document, parse_html_document, parse_js_document};
use crate::path_display::{format_uri_for_display, to_normalized_fs_path, PathDisplayOptions};
use crate::runtime_config::{RuntimeConfig, UndefinedVarFallbackMode};
use crate::specificity::{
calculate_specificity, compare_specificity, format_specificity, matches_context,
sort_by_cascade,
};
use crate::text_utils::{
apply_change_to_text, clamp_to_char_boundary, find_value_range_in_definition, is_word_byte,
is_word_char, range_contains, range_contains_position,
};
use crate::types::{position_to_offset, Config, CssVariable};
use crate::VERSION;
fn variable_source_label(variable: &CssVariable) -> Option<&'static str> {
config_variable_source(&variable.uri).map(|source| source.label())
}
fn variable_completion_detail(variable: &CssVariable) -> String {
match (variable.value.is_empty(), variable_source_label(variable)) {
(true, Some(source)) => source.to_string(),
(false, Some(source)) => format!("{} • {}", variable.value, source),
(_, None) => variable.value.clone(),
}
}
fn variable_completion_documentation(variable: &CssVariable, display_path: &str) -> String {
if let Some(source) = variable_source_label(variable) {
format!("Generated by {}\n\nDefined in {}", source, display_path)
} else {
format!("Defined in {}", display_path)
}
}
fn code_actions_for_undefined_variables(
uri: &Uri,
text: &str,
context: &CodeActionContext,
runtime_config: &RuntimeConfig,
allow_create_definition: bool,
) -> Vec<CodeActionOrCommand> {
let mut actions = Vec::new();
for diag in &context.diagnostics {
let code = match diag.code.as_ref() {
Some(ls_types::NumberOrString::String(code)) => code.as_str(),
_ => continue,
};
if code != "css-variable-lsp.undefined-variable" {
continue;
}
let name = diag
.data
.as_ref()
.and_then(|d| d.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let name = match name {
Some(name) => name,
None => continue,
};
if allow_create_definition {
let insert_text = format!(":root {{\n {}: ;\n}}\n\n", name);
let edit = WorkspaceEdit {
changes: Some(HashMap::from([(
uri.clone(),
vec![TextEdit {
range: Range::new(Position::new(0, 0), Position::new(0, 0)),
new_text: insert_text,
}],
)])),
document_changes: None,
change_annotations: None,
};
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Create {} in :root", name),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(edit),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
}));
}
if runtime_config.suggest_add_fallback {
if let (Some(start), Some(end)) = (
crate::types::position_to_offset(text, diag.range.start),
crate::types::position_to_offset(text, diag.range.end),
) {
if start < end && end <= text.len() {
let slice = &text[start..end];
if slice.starts_with("var(") && slice.ends_with(')') && !slice.contains(',') {
let new_text = slice.trim_end_matches(')').to_string() + ", )";
let edit = WorkspaceEdit {
changes: Some(HashMap::from([(
uri.clone(),
vec![TextEdit {
range: diag.range,
new_text,
}],
)])),
document_changes: None,
change_annotations: None,
};
let action = CodeAction {
title: format!("Add fallback to {}", name),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(edit),
command: None,
is_preferred: Some(false),
disabled: None,
data: None,
};
actions.push(CodeActionOrCommand::CodeAction(action));
}
}
}
}
}
actions
}
fn code_actions_for_replaceable_literal_colors(
uri: &Uri,
context: &CodeActionContext,
runtime_config: &RuntimeConfig,
) -> Vec<CodeActionOrCommand> {
if !runtime_config.suggest_exact_color_variables {
return Vec::new();
}
let mut actions = Vec::new();
for diag in &context.diagnostics {
let code = match diag.code.as_ref() {
Some(ls_types::NumberOrString::String(code)) => code.as_str(),
_ => continue,
};
if code != "css-variable-lsp.literal-color-replaceable" {
continue;
}
let replacements = diag
.data
.as_ref()
.and_then(|d| d.get("replacements"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for replacement in replacements {
let name = match replacement.get("name").and_then(|v| v.as_str()) {
Some(name) => name,
None => continue,
};
let edit = WorkspaceEdit {
changes: Some(HashMap::from([(
uri.clone(),
vec![TextEdit {
range: diag.range,
new_text: format!("var({})", name),
}],
)])),
document_changes: None,
change_annotations: None,
};
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Replace with var({})", name),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: Some(edit),
command: None,
is_preferred: Some(false),
disabled: None,
data: None,
}));
}
}
actions
}
pub struct CssVariableLsp {
client: Client,
manager: Arc<CssVariableManager>,
document_map: Arc<RwLock<HashMap<Uri, String>>>,
document_lifecycle_lock: Arc<Mutex<()>>,
runtime_config: RuntimeConfig,
workspace_folder_paths: Arc<RwLock<Vec<PathBuf>>>,
root_folder_path: Arc<RwLock<Option<PathBuf>>>,
root_folder_uri: Arc<RwLock<Option<Uri>>>,
has_workspace_folder_capability: Arc<RwLock<bool>>,
has_diagnostic_related_information: Arc<RwLock<bool>>,
usage_regex: Regex,
var_usage_regex: Regex,
lookup_extension_map: Arc<RwLock<HashMap<String, DocumentKind>>>,
live_config: Arc<RwLock<Config>>,
document_language_map: Arc<RwLock<HashMap<Uri, String>>>,
document_usage_map: Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
usage_index: Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
color_occurrence_uris: Arc<RwLock<HashSet<Uri>>>,
}
impl CssVariableLsp {
pub fn new(client: Client, runtime_config: RuntimeConfig) -> Self {
let config = Config::from_runtime(&runtime_config);
let lookup_extension_map = build_lookup_extension_map(&config.lookup_files);
let live_config = config.clone();
Self {
client,
manager: Arc::new(CssVariableManager::new(config)),
document_map: Arc::new(RwLock::new(HashMap::new())),
runtime_config,
document_lifecycle_lock: Arc::new(Mutex::new(())),
workspace_folder_paths: Arc::new(RwLock::new(Vec::new())),
root_folder_path: Arc::new(RwLock::new(None)),
root_folder_uri: Arc::new(RwLock::new(None)),
has_workspace_folder_capability: Arc::new(RwLock::new(false)),
has_diagnostic_related_information: Arc::new(RwLock::new(false)),
usage_regex: Regex::new(r"var\((--[\w-]+)(?:\s*,\s*([^)]+))?\)").unwrap(),
var_usage_regex: Regex::new(r"var\((--[\w-]+)\)").unwrap(),
lookup_extension_map: Arc::new(RwLock::new(lookup_extension_map)),
live_config: Arc::new(RwLock::new(live_config)),
document_language_map: Arc::new(RwLock::new(HashMap::new())),
document_usage_map: Arc::new(RwLock::new(HashMap::new())),
usage_index: Arc::new(RwLock::new(HashMap::new())),
color_occurrence_uris: Arc::new(RwLock::new(HashSet::new())),
}
}
async fn update_workspace_folder_paths(&self, folders: Option<Vec<WorkspaceFolder>>) {
let mut paths = Vec::new();
if let Some(folders) = folders {
for folder in folders {
if let Some(path) = to_normalized_fs_path(&folder.uri) {
paths.push(path);
}
}
}
paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
let mut stored = self.workspace_folder_paths.write().await;
*stored = paths;
}
async fn workspace_folders_for_scan(&self) -> Option<Vec<WorkspaceFolder>> {
let supports_workspace_folders = *self.has_workspace_folder_capability.read().await;
let client_folders = if supports_workspace_folders {
self.client.workspace_folders().await.ok().flatten()
} else {
None
};
if let Some(folders) = client_folders.filter(|folders| !folders.is_empty()) {
return Some(folders);
}
self.root_folder_uri.read().await.clone().map(|uri| {
vec![WorkspaceFolder {
uri,
name: "root".to_string(),
}]
})
}
async fn parse_document_text(
&self,
uri: &Uri,
text: &str,
language_id: Option<&str>,
version: Option<i64>,
) {
let path = uri.path().as_str();
if is_supported_config_path(std::path::Path::new(path)) {
let result = match version {
Some(version) => {
parse_config_document_at_version(text, uri, &self.manager, version).await
}
None => parse_config_document(text, uri, &self.manager).await,
};
if let Err(e) = result {
self.client
.log_message(
MessageType::ERROR,
format!("Configuration parse error: {}", e),
)
.await;
}
self.manager.rebuild_color_index().await;
return;
}
self.manager.remove_document(uri).await;
let lookup_map = self.lookup_extension_map.read().await.clone();
let kind = resolve_document_kind(path, language_id, &lookup_map);
let result = match kind {
Some(DocumentKind::Html) => parse_html_document(text, uri, &self.manager).await,
Some(DocumentKind::Css) => parse_css_document(text, uri, &self.manager).await,
Some(DocumentKind::Js) => parse_js_document(text, uri, &self.manager).await,
None => return,
};
if let Err(e) = result {
self.client
.log_message(MessageType::ERROR, format!("Parse error: {}", e))
.await;
}
self.manager.rebuild_color_index().await;
}
async fn validate_document_text(&self, uri: &Uri, text: &str) {
let has_related_info = *self.has_diagnostic_related_information.read().await;
validate_document_text_with(
&self.client,
self.manager.as_ref(),
&self.usage_regex,
self.runtime_config.undefined_var_fallback,
has_related_info,
uri,
text,
&self.document_usage_map,
&self.usage_index,
Some(&self.color_occurrence_uris),
)
.await;
}
async fn validate_all_open_documents(&self) {
let has_related_info = *self.has_diagnostic_related_information.read().await;
let docs_snapshot = {
let docs = self.document_map.read().await;
docs.iter()
.map(|(uri, text)| (uri.clone(), text.clone()))
.collect::<Vec<_>>()
};
for (uri, text) in docs_snapshot {
validate_document_text_with(
&self.client,
self.manager.as_ref(),
&self.usage_regex,
self.runtime_config.undefined_var_fallback,
has_related_info,
&uri,
&text,
&self.document_usage_map,
&self.usage_index,
Some(&self.color_occurrence_uris),
)
.await;
}
}
async fn revalidate_affected_documents(
&self,
changed_names: &HashSet<String>,
exclude_uri: Option<&Uri>,
revalidate_color_occurrences: bool,
) {
if changed_names.is_empty() && !revalidate_color_occurrences {
return;
}
let mut affected_uris = HashSet::new();
{
let index = self.usage_index.read().await;
for name in changed_names {
if let Some(uris) = index.get(name) {
for uri in uris {
if exclude_uri != Some(uri) {
affected_uris.insert(uri.clone());
}
}
}
}
}
if revalidate_color_occurrences {
let color_uris = self.color_occurrence_uris.read().await;
for uri in color_uris.iter() {
if exclude_uri != Some(uri) {
affected_uris.insert(uri.clone());
}
}
}
if affected_uris.is_empty() {
return;
}
let has_related_info = *self.has_diagnostic_related_information.read().await;
let affected_snapshot = {
let docs = self.document_map.read().await;
affected_uris
.into_iter()
.filter_map(|uri| docs.get(&uri).map(|text| (uri.clone(), text.clone())))
.collect::<Vec<_>>()
};
for (uri, text) in affected_snapshot {
validate_document_text_with(
&self.client,
self.manager.as_ref(),
&self.usage_regex,
self.runtime_config.undefined_var_fallback,
has_related_info,
&uri,
&text,
&self.document_usage_map,
&self.usage_index,
Some(&self.color_occurrence_uris),
)
.await;
}
}
async fn update_document_from_disk(&self, uri: &Uri) {
let path = match to_normalized_fs_path(uri) {
Some(path) => path,
None => {
self.manager.remove_document(uri).await;
return;
}
};
match tokio::fs::read_to_string(&path).await {
Ok(text) => {
self.parse_document_text(uri, &text, None, None).await;
}
Err(_) => {
self.manager.remove_document(uri).await;
self.manager.rebuild_color_index().await;
}
}
}
async fn apply_content_changes(
&self,
uri: &Uri,
changes: Vec<TextDocumentContentChangeEvent>,
) -> Option<String> {
let mut docs = self.document_map.write().await;
let mut text = if let Some(existing) = docs.get(uri) {
existing.clone()
} else {
if changes.len() == 1 && changes[0].range.is_none() {
let new_text = changes[0].text.clone();
docs.insert(uri.clone(), new_text.clone());
return Some(new_text);
}
return None;
};
for change in changes {
apply_change_to_text(&mut text, &change);
}
docs.insert(uri.clone(), text.clone());
Some(text)
}
fn get_word_at_position(&self, text: &str, position: Position) -> Option<String> {
self.get_word_and_range_at_position(text, position)
.map(|(word, _)| word)
}
fn get_word_and_range_at_position(
&self,
text: &str,
position: Position,
) -> Option<(String, Range)> {
let offset = position_to_offset(text, position)?;
let offset = clamp_to_char_boundary(text, offset);
let before = &text[..offset];
let after = &text[offset..];
let left = before
.rsplit(|c: char| !is_word_char(c))
.next()
.unwrap_or("");
let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
let word = format!("{}{}", left, right);
if word.starts_with("--") {
let start = offset - left.len();
let end = offset + right.len();
Some((
word,
Range::new(
crate::types::offset_to_position(text, start),
crate::types::offset_to_position(text, end),
),
))
} else {
None
}
}
async fn is_document_open(&self, uri: &Uri) -> bool {
let docs = self.document_map.read().await;
docs.contains_key(uri)
}
}
impl LanguageServer for CssVariableLsp {
async fn initialize(
&self,
params: InitializeParams,
) -> tower_lsp_server::jsonrpc::Result<InitializeResult> {
self.client
.log_message(
MessageType::INFO,
&format!("CSS Variable LSP (Rust) v{} initializing...", VERSION),
)
.await;
let has_workspace_folders = params
.capabilities
.workspace
.as_ref()
.and_then(|w| w.workspace_folders)
.unwrap_or(false);
let has_related_info = params
.capabilities
.text_document
.as_ref()
.and_then(|t| t.publish_diagnostics.as_ref())
.and_then(|p| p.related_information)
.unwrap_or(false);
{
let mut cap = self.has_workspace_folder_capability.write().await;
*cap = has_workspace_folders;
}
{
let mut rel = self.has_diagnostic_related_information.write().await;
*rel = has_related_info;
}
let root_uri_for_scan = {
#[allow(deprecated)]
if let Some(root_uri) = params.root_uri.clone() {
Some(root_uri)
} else {
#[allow(deprecated)]
params.root_path.as_ref().and_then(Uri::from_file_path)
}
};
#[allow(deprecated)]
let legacy_root_path = params.root_path.as_ref().map(PathBuf::from);
{
let mut root_uri = self.root_folder_uri.write().await;
*root_uri = root_uri_for_scan.clone();
}
{
let mut root = self.root_folder_path.write().await;
*root = root_uri_for_scan
.as_ref()
.and_then(to_normalized_fs_path)
.or(legacy_root_path);
}
self.update_workspace_folder_paths(params.workspace_folders.clone())
.await;
let mut capabilities = ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
completion_provider: Some(CompletionOptions {
resolve_provider: Some(true),
trigger_characters: Some(vec!["-".to_string(), "(".to_string(), ":".to_string()]),
work_done_progress_options: WorkDoneProgressOptions::default(),
all_commit_characters: None,
completion_item: None,
}),
hover_provider: Some(ls_types::HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_provider: Some(OneOf::Left(true)),
color_provider: if self.runtime_config.enable_color_provider {
Some(ColorProviderCapability::Simple(true))
} else {
None
},
..Default::default()
};
if has_workspace_folders {
capabilities.workspace = Some(WorkspaceServerCapabilities {
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: Some(OneOf::Left(true)),
}),
file_operations: None,
});
}
Ok(InitializeResult {
capabilities,
offset_encoding: Some("utf-16".to_string()),
server_info: Some(ls_types::ServerInfo {
name: "css-variable-lsp-rust".to_string(),
version: Some(VERSION.to_string()),
}),
})
}
async fn initialized(&self, _params: ls_types::InitializedParams) {
self.client
.log_message(
MessageType::INFO,
&format!("CSS Variable LSP (Rust) v{} initialized!", VERSION),
)
.await;
if let Some(folders) = self.workspace_folders_for_scan().await {
self.update_workspace_folder_paths(Some(folders.clone()))
.await;
self.scan_workspace_folders(folders).await;
}
}
async fn shutdown(&self) -> tower_lsp_server::jsonrpc::Result<()> {
Ok(())
}
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
let settings = params
.settings
.get("cssVariableLsp")
.cloned()
.unwrap_or(params.settings);
let patch = serde_json::from_value::<ClientConfigPatch>(settings);
let patch = match patch {
Ok(patch) => patch,
Err(err) => {
self.client
.log_message(
MessageType::WARNING,
format!("Failed to parse didChangeConfiguration settings: {}", err),
)
.await;
return;
}
};
let mut config = self.live_config.read().await.clone();
let prev_lookup_files = config.lookup_files.clone();
let prev_ignore_globs = config.ignore_globs.clone();
config = apply_config_patch(config, patch);
{
let mut stored = self.live_config.write().await;
*stored = config.clone();
}
self.manager.set_config(config.clone()).await;
if config.lookup_files != prev_lookup_files {
let new_map = build_lookup_extension_map(&config.lookup_files);
let mut stored = self.lookup_extension_map.write().await;
*stored = new_map;
}
if config.lookup_files != prev_lookup_files || config.ignore_globs != prev_ignore_globs {
if let Some(folders) = self.workspace_folders_for_scan().await {
self.scan_workspace_folders(folders).await;
}
}
self.validate_all_open_documents().await;
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
let version = i64::from(params.text_document.version);
let uri = params.text_document.uri;
let text = params.text_document.text;
let language_id = params.text_document.language_id;
if !self.manager.accept_document_version(&uri, version).await {
return;
}
let old_names = self.manager.get_document_variable_names(&uri).await;
let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
{
let mut docs = self.document_map.write().await;
docs.insert(uri.clone(), text.clone());
}
{
let mut langs = self.document_language_map.write().await;
langs.insert(uri.clone(), language_id.clone());
}
self.parse_document_text(&uri, &text, Some(&language_id), Some(version))
.await;
let new_names = self.manager.get_document_variable_names(&uri).await;
let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
self.validate_document_text(&uri, &text).await;
let colors_changed = old_colors != new_colors;
if old_names != new_names || colors_changed {
let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
let mut changed_names = added_names;
changed_names.extend(removed_names);
self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
.await;
}
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
let version = i64::from(params.text_document.version);
let uri = params.text_document.uri;
let changes = params.content_changes;
if !self.manager.accept_document_version(&uri, version).await {
return;
}
let old_names = self.manager.get_document_variable_names(&uri).await;
let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
let updated_text = match self.apply_content_changes(&uri, changes).await {
Some(text) => text,
None => return,
};
let language_id = {
let langs = self.document_language_map.read().await;
langs.get(&uri).cloned()
};
self.parse_document_text(&uri, &updated_text, language_id.as_deref(), Some(version))
.await;
let new_names = self.manager.get_document_variable_names(&uri).await;
let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
self.validate_document_text(&uri, &updated_text).await;
let colors_changed = old_colors != new_colors;
if old_names != new_names || colors_changed {
let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
let mut changed_names = added_names;
changed_names.extend(removed_names);
self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
.await;
}
}
async fn will_save(&self, _params: WillSaveTextDocumentParams) {
}
async fn will_save_wait_until(
&self,
_params: WillSaveTextDocumentParams,
) -> tower_lsp_server::jsonrpc::Result<Option<Vec<TextEdit>>> {
Ok(None)
}
async fn did_save(&self, _params: DidSaveTextDocumentParams) {
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
let uri = params.text_document.uri;
let old_names = self.manager.get_document_variable_names(&uri).await;
let old_colors = self.manager.get_document_resolved_color_keys(&uri).await;
{
let mut docs = self.document_map.write().await;
docs.remove(&uri);
}
{
let mut langs = self.document_language_map.write().await;
langs.remove(&uri);
}
{
let mut usage_map = self.document_usage_map.write().await;
if let Some(old_usages) = usage_map.remove(&uri) {
let mut index = self.usage_index.write().await;
for name in old_usages {
if let Some(uris) = index.get_mut(&name) {
uris.remove(&uri);
if uris.is_empty() {
index.remove(&name);
}
}
}
}
}
self.manager.clear_document_version(&uri).await;
self.update_document_from_disk(&uri).await;
let new_names = self.manager.get_document_variable_names(&uri).await;
let new_colors = self.manager.get_document_resolved_color_keys(&uri).await;
let colors_changed = old_colors != new_colors;
if old_names != new_names || colors_changed {
let added_names: HashSet<_> = new_names.difference(&old_names).cloned().collect();
let removed_names: HashSet<_> = old_names.difference(&new_names).cloned().collect();
let mut changed_names = added_names;
changed_names.extend(removed_names);
self.revalidate_affected_documents(&changed_names, Some(&uri), colors_changed)
.await;
}
}
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
for change in params.changes {
match change.typ {
FileChangeType::DELETED => {
self.manager.remove_document(&change.uri).await;
}
FileChangeType::CREATED | FileChangeType::CHANGED
if !self.is_document_open(&change.uri).await =>
{
self.update_document_from_disk(&change.uri).await;
}
_ => {}
}
}
self.validate_all_open_documents().await;
}
async fn did_create_files(&self, params: CreateFilesParams) {
for file in params.files {
let uri = match Uri::from_str(&file.uri) {
Ok(uri) => uri,
Err(_) => continue,
};
if !self.is_document_open(&uri).await {
self.update_document_from_disk(&uri).await;
}
}
self.validate_all_open_documents().await;
}
async fn did_rename_files(&self, params: RenameFilesParams) {
for file in params.files {
let old_uri = match Uri::from_str(&file.old_uri) {
Ok(uri) => uri,
Err(_) => continue,
};
let new_uri = match Uri::from_str(&file.new_uri) {
Ok(uri) => uri,
Err(_) => continue,
};
self.manager.remove_document(&old_uri).await;
if !self.is_document_open(&new_uri).await {
self.update_document_from_disk(&new_uri).await;
}
}
self.validate_all_open_documents().await;
}
async fn did_delete_files(&self, params: DeleteFilesParams) {
for file in params.files {
let uri = match Uri::from_str(&file.uri) {
Ok(uri) => uri,
Err(_) => continue,
};
self.manager.remove_document(&uri).await;
}
self.validate_all_open_documents().await;
}
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
let mut current_paths = {
let paths = self.workspace_folder_paths.read().await;
paths.clone()
};
let mut removed_paths = Vec::new();
for removed in ¶ms.event.removed {
if let Some(path) = to_normalized_fs_path(&removed.uri) {
current_paths.retain(|p| p != &path);
removed_paths.push(path);
}
}
let mut added_folders = Vec::new();
for added in params.event.added {
if let Some(path) = to_normalized_fs_path(&added.uri) {
if !current_paths.contains(&path) {
current_paths.push(path);
added_folders.push(added);
}
}
}
current_paths.sort_by_key(|b| std::cmp::Reverse(b.to_string_lossy().len()));
{
let mut stored = self.workspace_folder_paths.write().await;
*stored = current_paths.clone();
}
if !removed_paths.is_empty() {
let open_uris: HashSet<Uri> = self.document_map.read().await.keys().cloned().collect();
let stale_uris: HashSet<Uri> = self
.manager
.get_document_uris()
.await
.into_iter()
.filter(|uri| !open_uris.contains(uri))
.filter(|uri| {
let Some(path) = to_normalized_fs_path(uri) else {
return false;
};
removed_paths
.iter()
.any(|removed| path.starts_with(removed))
&& !current_paths
.iter()
.any(|current| path.starts_with(current))
})
.collect();
self.manager.remove_documents(&stale_uris).await;
}
if added_folders.is_empty() {
self.validate_all_open_documents().await;
} else {
self.scan_workspace_folders(added_folders).await;
}
}
async fn completion(
&self,
params: CompletionParams,
) -> tower_lsp_server::jsonrpc::Result<Option<CompletionResponse>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
};
let language_id = {
let langs = self.document_language_map.read().await;
langs.get(&uri).cloned()
};
let lookup_map = self.lookup_extension_map.read().await.clone();
let context = completion_value_context_slice(
&text,
position,
language_id.as_deref(),
&uri,
&lookup_map,
);
let context = match context {
Some(context) => context,
None => return Ok(Some(CompletionResponse::Array(Vec::new()))),
};
let value_context = get_value_context_info(context.slice, context.allow_without_braces);
if !value_context.is_value_context {
return Ok(Some(CompletionResponse::Array(Vec::new())));
}
let property_name = value_context.property_name;
let in_var_context = is_var_function_context_slice(context.slice);
let workspace_folder_paths = self.workspace_folder_paths.read().await.clone();
let root_folder_path = self.root_folder_path.read().await.clone();
if !in_var_context {
if let Some(color_key) = self.literal_color_under_cursor(&uri, position).await {
let variables = self.manager.get_variables_by_color_key(&color_key).await;
let items = variables
.into_iter()
.map(|var| {
let options = PathDisplayOptions {
mode: self.runtime_config.path_display_mode,
abbrev_length: self.runtime_config.path_display_abbrev_length,
workspace_folder_paths: &workspace_folder_paths,
root_folder_path: root_folder_path.as_ref(),
};
let display_path = format_uri_for_display(&var.uri, options);
CompletionItem {
label: var.name.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some(format!(
"{} • {}",
variable_completion_detail(&var),
display_path
)),
insert_text: Some(format!("var({})", var.name)),
documentation: Some(ls_types::Documentation::String(
variable_completion_documentation(&var, &display_path),
)),
..Default::default()
}
})
.collect();
return Ok(Some(CompletionResponse::Array(items)));
}
}
let variables = self.manager.get_all_variables().await;
let mut unique_vars = HashMap::new();
for var in variables {
unique_vars.entry(var.name.clone()).or_insert(var);
}
let mut scored_vars: Vec<(i32, _)> = unique_vars
.values()
.map(|var| {
let score = score_variable_relevance(&var.name, property_name.as_deref());
(score, var)
})
.collect();
scored_vars.retain(|(score, _)| *score != 0);
scored_vars.sort_by(|(score_a, var_a), (score_b, var_b)| {
if score_a != score_b {
return score_b.cmp(score_a);
}
var_a.name.cmp(&var_b.name)
});
let items = scored_vars
.into_iter()
.map(|(_, var)| {
let options = PathDisplayOptions {
mode: self.runtime_config.path_display_mode,
abbrev_length: self.runtime_config.path_display_abbrev_length,
workspace_folder_paths: &workspace_folder_paths,
root_folder_path: root_folder_path.as_ref(),
};
let insert_text = if in_var_context {
var.name.clone()
} else {
format!("var({})", var.name)
};
CompletionItem {
label: var.name.clone(),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some(variable_completion_detail(var)),
insert_text: Some(insert_text),
documentation: Some(ls_types::Documentation::String(
variable_completion_documentation(
var,
&format_uri_for_display(&var.uri, options),
),
)),
..Default::default()
}
})
.collect();
Ok(Some(CompletionResponse::Array(items)))
}
async fn completion_resolve(
&self,
item: CompletionItem,
) -> tower_lsp_server::jsonrpc::Result<CompletionItem> {
Ok(item)
}
async fn hover(&self, params: HoverParams) -> tower_lsp_server::jsonrpc::Result<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(None),
};
let word = match self.get_word_at_position(&text, position) {
Some(word) => word,
None => return Ok(None),
};
let mut definitions = self.manager.get_variables(&word).await;
if definitions.is_empty() {
return Ok(None);
}
let usages = self.manager.get_usages(&word).await;
let offset = match position_to_offset(&text, position) {
Some(offset) => offset,
None => return Ok(None),
};
let hover_usage = usages.iter().find(|usage| {
if usage.uri != uri {
return false;
}
let start = position_to_offset(&text, usage.range.start).unwrap_or(0);
let end = position_to_offset(&text, usage.range.end).unwrap_or(0);
offset >= start && offset <= end
});
let usage_context = hover_usage
.map(|u| u.usage_context.clone())
.unwrap_or_default();
let is_inline_style = usage_context == "inline-style";
let dom_tree = self.manager.get_dom_tree(&uri).await;
let dom_node = hover_usage.and_then(|u| u.dom_node.clone());
sort_by_cascade(&mut definitions);
let mut hover_text = format!("### CSS Variable: `{}`\n\n", word);
if definitions.len() == 1 {
let var = &definitions[0];
if !var.value.is_empty() {
hover_text.push_str(&format!("**Value:** `{}`", var.value));
if var.important {
hover_text.push_str(" **!important**");
}
hover_text.push_str("\n\n");
}
if let Some(source) = variable_source_label(var) {
hover_text.push_str(&format!("**Generated by:** {}\n\n", source));
}
if !var.selector.is_empty() {
let scope_label = if variable_source_label(var).is_some() {
"CSS scope"
} else {
"Defined in"
};
hover_text.push_str(&format!("**{}:** `{}`\n", scope_label, var.selector));
hover_text.push_str(&format!(
"**Specificity:** {}\n",
format_specificity(calculate_specificity(&var.selector))
));
}
} else {
hover_text.push_str("**Definitions** (CSS cascade order):\n\n");
for (idx, var) in definitions.iter().enumerate() {
let spec = calculate_specificity(&var.selector);
let is_applicable = if usage_context.is_empty() {
true
} else {
matches_context(
&var.selector,
&usage_context,
dom_tree.as_ref(),
dom_node.as_ref(),
)
};
let is_winner = idx == 0 && (is_applicable || is_inline_style);
let mut line = if var.value.is_empty() {
format!("{}.", idx + 1)
} else {
format!("{}. `{}`", idx + 1, var.value)
};
if let Some(source) = variable_source_label(var) {
line.push_str(&format!(" generated by {}", source));
}
if var.important {
line.push_str(" **!important**");
}
if !var.selector.is_empty() {
line.push_str(&format!(
" from `{}` {}",
var.selector,
format_specificity(spec)
));
}
if is_winner && !usage_context.is_empty() {
if var.important {
line.push_str(" ✓ **Wins (!important)**");
} else if is_inline_style {
line.push_str(" ✓ **Would apply (inline style)**");
} else if dom_tree.is_some() && dom_node.is_some() {
line.push_str(" ✓ **Applies (DOM match)**");
} else {
line.push_str(" ✓ **Applies here**");
}
} else if !is_applicable && !usage_context.is_empty() && !is_inline_style {
line.push_str(" _(selector doesn't match)_");
} else if idx > 0 && !usage_context.is_empty() {
let winner = &definitions[0];
if winner.important && !var.important {
line.push_str(" _(overridden by !important)_");
} else {
let winner_spec = calculate_specificity(&winner.selector);
let cmp = compare_specificity(winner_spec, spec);
if cmp > 0 {
line.push_str(" _(lower specificity)_");
} else if cmp == 0 {
line.push_str(" _(earlier in source)_");
}
}
}
hover_text.push_str(&line);
hover_text.push('\n');
}
if !usage_context.is_empty() {
if is_inline_style {
hover_text.push_str("\n_Context: Inline style (highest priority)_");
} else if dom_tree.is_some() && dom_node.is_some() {
hover_text.push_str(&format!(
"\n_Context: `{}` (DOM-aware matching)_",
usage_context
));
} else {
hover_text.push_str(&format!("\n_Context: `{}`_", usage_context));
}
}
}
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: hover_text,
}),
range: None,
}))
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> tower_lsp_server::jsonrpc::Result<Option<GotoDefinitionResponse>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(None),
};
let word = match self.get_word_at_position(&text, position) {
Some(word) => word,
None => return Ok(None),
};
let definitions = self.manager.get_variables(&word).await;
let first = match definitions.first() {
Some(def) => def,
None => return Ok(None),
};
Ok(Some(GotoDefinitionResponse::Scalar(Location::new(
first.uri.clone(),
first.range,
))))
}
async fn code_action(
&self,
params: CodeActionParams,
) -> tower_lsp_server::jsonrpc::Result<Option<CodeActionResponse>> {
let uri = params.text_document.uri;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(Some(Vec::new())),
};
let mut actions: Vec<CodeActionOrCommand> = Vec::new();
let language_id = {
let languages = self.document_language_map.read().await;
languages.get(&uri).cloned()
};
let lookup_map = self.lookup_extension_map.read().await.clone();
let allow_create_definition = matches!(
resolve_document_kind(uri.path().as_str(), language_id.as_deref(), &lookup_map),
Some(DocumentKind::Css)
);
actions.extend(code_actions_for_undefined_variables(
&uri,
&text,
¶ms.context,
&self.runtime_config,
allow_create_definition,
));
actions.extend(code_actions_for_replaceable_literal_colors(
&uri,
¶ms.context,
&self.runtime_config,
));
Ok(Some(actions))
}
async fn references(
&self,
params: ReferenceParams,
) -> tower_lsp_server::jsonrpc::Result<Option<Vec<Location>>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(None),
};
let word = match self.get_word_at_position(&text, position) {
Some(word) => word,
None => return Ok(None),
};
let (definitions, usages) = self.manager.get_references(&word).await;
let mut locations = Vec::new();
for def in definitions {
locations.push(Location::new(def.uri, def.range));
}
for usage in usages {
locations.push(Location::new(usage.uri, usage.range));
}
Ok(Some(locations))
}
async fn document_color(
&self,
params: DocumentColorParams,
) -> tower_lsp_server::jsonrpc::Result<Vec<ColorInformation>> {
let config = self.manager.get_config().await;
if !config.enable_color_provider {
return Ok(Vec::new());
}
let uri = params.text_document.uri;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(Vec::new()),
};
let mut colors = Vec::new();
let mut seen_ranges: HashSet<(u32, u32, u32, u32)> = HashSet::new();
let range_key = |range: &Range| {
(
range.start.line,
range.start.character,
range.end.line,
range.end.character,
)
};
if !config.color_only_on_variables {
let definitions = self.manager.get_document_variables(&uri).await;
for def in definitions {
if let Some(color) = parse_color(&def.value) {
if let Some(value_range) = def.value_range {
if seen_ranges.insert(range_key(&value_range)) {
colors.push(ColorInformation {
range: value_range,
color,
});
}
} else if let Some(range) = find_value_range_in_definition(&text, &def) {
if seen_ranges.insert(range_key(&range)) {
colors.push(ColorInformation { range, color });
}
}
}
}
}
let usages = self.manager.get_document_usages(&uri).await;
for usage in usages {
if let Some(color) = self.manager.resolve_variable_color(&usage.name).await {
if seen_ranges.insert(range_key(&usage.range)) {
colors.push(ColorInformation {
range: usage.range,
color,
});
}
}
}
for caps in self.var_usage_regex.captures_iter(&text) {
let match_all = caps.get(0).unwrap();
let var_name = caps.get(1).unwrap().as_str();
let range = Range::new(
crate::types::offset_to_position(&text, match_all.start()),
crate::types::offset_to_position(&text, match_all.end()),
);
if !seen_ranges.insert(range_key(&range)) {
continue;
}
if let Some(color) = self.manager.resolve_variable_color(var_name).await {
colors.push(ColorInformation { range, color });
}
}
Ok(colors)
}
async fn color_presentation(
&self,
params: ColorPresentationParams,
) -> tower_lsp_server::jsonrpc::Result<Vec<ColorPresentation>> {
if !self.runtime_config.enable_color_provider {
return Ok(Vec::new());
}
Ok(generate_color_presentations(params.color, params.range))
}
async fn prepare_rename(
&self,
params: TextDocumentPositionParams,
) -> tower_lsp_server::jsonrpc::Result<Option<PrepareRenameResponse>> {
let uri = params.text_document.uri;
let position = params.position;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(None),
};
let (_, range) = match self.get_word_and_range_at_position(&text, position) {
Some(word_and_range) => word_and_range,
None => return Ok(None),
};
Ok(Some(PrepareRenameResponse::Range(range)))
}
async fn rename(
&self,
params: RenameParams,
) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceEdit>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let new_name = params.new_name;
let text = {
let docs = self.document_map.read().await;
docs.get(&uri).cloned()
};
let text = match text {
Some(text) => text,
None => return Ok(None),
};
let old_name = match self.get_word_at_position(&text, position) {
Some(word) => word,
None => return Ok(None),
};
let (definitions, usages) = self.manager.get_references(&old_name).await;
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
for def in definitions {
let range = def.name_range.unwrap_or(def.range);
changes.entry(def.uri.clone()).or_default().push(TextEdit {
range,
new_text: new_name.clone(),
});
}
for usage in usages {
let range = usage.name_range.unwrap_or(usage.range);
changes
.entry(usage.uri.clone())
.or_default()
.push(TextEdit {
range,
new_text: new_name.clone(),
});
}
Ok(Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}))
}
#[allow(deprecated)]
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> tower_lsp_server::jsonrpc::Result<Option<DocumentSymbolResponse>> {
let vars = self
.manager
.get_document_variables(¶ms.text_document.uri)
.await;
let symbols: Vec<DocumentSymbol> = vars
.into_iter()
.map(|var| {
let detail = variable_completion_detail(&var);
DocumentSymbol {
name: var.name,
detail: Some(detail),
kind: SymbolKind::VARIABLE,
tags: None,
deprecated: None,
range: var.range,
selection_range: var.range,
children: None,
}
})
.collect();
Ok(Some(DocumentSymbolResponse::Nested(symbols)))
}
#[allow(deprecated)]
async fn symbol(
&self,
params: WorkspaceSymbolParams,
) -> tower_lsp_server::jsonrpc::Result<Option<WorkspaceSymbolResponse>> {
let query = params.query.to_lowercase();
let vars = self.manager.get_all_variables().await;
let mut symbols = Vec::new();
for var in vars {
if !query.is_empty() && !var.name.to_lowercase().contains(&query) {
continue;
}
symbols.push(SymbolInformation {
name: var.name,
kind: SymbolKind::VARIABLE,
tags: None,
deprecated: None,
location: Location::new(var.uri.clone(), var.range),
container_name: None,
});
}
Ok(Some(WorkspaceSymbolResponse::Flat(symbols)))
}
}
impl CssVariableLsp {
async fn literal_color_under_cursor(
&self,
uri: &Uri,
position: Position,
) -> Option<NormalizedColorKey> {
let occurrences = self
.manager
.get_literal_colors_at_position(uri, position)
.await;
occurrences
.into_iter()
.find(|occurrence| range_contains_position(&occurrence.range, position))
.map(|occurrence| occurrence.normalized_color)
}
pub async fn scan_workspace_folders(&self, folders: Vec<WorkspaceFolder>) {
let folder_uris: Vec<Uri> = folders.iter().map(|f| f.uri.clone()).collect();
self.client
.log_message(
MessageType::INFO,
format!("Scanning {} workspace folders...", folder_uris.len()),
)
.await;
let manager = self.manager.clone();
let client = self.client.clone();
let mut last_logged_percentage = 0;
let result = crate::workspace::scan_workspace(folder_uris, &manager, |current, total| {
if total == 0 {
return;
}
let percentage = ((current as f64 / total as f64) * 100.0).round() as i32;
if percentage - last_logged_percentage >= 20 || current == total {
last_logged_percentage = percentage;
let client = client.clone();
tokio::spawn(async move {
client
.log_message(
MessageType::INFO,
format!(
"Scanning CSS files: {}/{} ({}%)",
current, total, percentage
),
)
.await;
});
}
})
.await;
match result {
Ok(_) => {
let total_vars = manager.get_all_variables().await.len();
self.client
.log_message(
MessageType::INFO,
format!(
"Workspace scan complete. Found {} CSS variables.",
total_vars
),
)
.await;
}
Err(e) => {
self.client
.log_message(MessageType::ERROR, format!("Workspace scan failed: {}", e))
.await;
}
}
let _document_lifecycle_guard = self.document_lifecycle_lock.lock().await;
let open_documents = {
let documents = self.document_map.read().await;
let languages = self.document_language_map.read().await;
documents
.iter()
.map(|(uri, text)| (uri.clone(), text.clone(), languages.get(uri).cloned()))
.collect::<Vec<_>>()
};
for (uri, text, language_id) in open_documents {
let version = if is_supported_config_path(std::path::Path::new(uri.path().as_str())) {
self.manager.get_document_version(&uri).await
} else {
None
};
self.parse_document_text(&uri, &text, language_id.as_deref(), version)
.await;
}
self.validate_all_open_documents().await;
}
}
#[allow(clippy::too_many_arguments)]
async fn validate_document_text_with(
client: &Client,
manager: &CssVariableManager,
usage_regex: &Regex,
undefined_var_fallback: UndefinedVarFallbackMode,
has_related_info: bool,
uri: &Uri,
text: &str,
document_usage_map: &Arc<RwLock<HashMap<Uri, HashSet<String>>>>,
usage_index: &Arc<RwLock<HashMap<String, HashSet<Uri>>>>,
color_occurrence_uris: Option<&Arc<RwLock<HashSet<Uri>>>>,
) {
let mut diagnostics = Vec::new();
let mut current_usages = HashSet::new();
let mut has_literal_colors = false;
let default_severity = DiagnosticSeverity::WARNING;
for captures in usage_regex.captures_iter(text) {
let match_all = captures.get(0).unwrap();
let name = captures.get(1).unwrap().as_str();
let fallback = captures.get(2).map(|m| m.as_str()).unwrap_or("");
let has_fallback = !fallback.trim().is_empty();
current_usages.insert(name.to_string());
let definitions = manager.get_variables(name).await;
if !definitions.is_empty() {
continue;
}
let severity = if has_fallback {
match undefined_var_fallback {
UndefinedVarFallbackMode::Warning => Some(default_severity),
UndefinedVarFallbackMode::Info => Some(DiagnosticSeverity::INFORMATION),
UndefinedVarFallbackMode::Off => None,
}
} else {
Some(default_severity)
};
let severity = match severity {
Some(severity) => severity,
None => continue,
};
let range = Range::new(
crate::types::offset_to_position(text, match_all.start()),
crate::types::offset_to_position(text, match_all.end()),
);
diagnostics.push(Diagnostic {
range,
severity: Some(severity),
code: Some(ls_types::NumberOrString::String(
"css-variable-lsp.undefined-variable".to_string(),
)),
code_description: None,
source: Some("css-variable-lsp".to_string()),
message: format!("CSS variable '{}' is not defined in the workspace", name),
related_information: if has_related_info {
Some(Vec::new())
} else {
None
},
tags: None,
data: Some(serde_json::json!({
"name": name,
"hasFallback": has_fallback,
"range": {
"start": { "line": range.start.line, "character": range.start.character },
"end": { "line": range.end.line, "character": range.end.character }
}
})),
});
}
for occurrence in manager.get_document_literal_colors(uri).await {
let all_replacements = manager
.get_variables_by_color_key(&occurrence.normalized_color)
.await;
let replacements: Vec<_> = all_replacements
.iter()
.filter(|var| {
if var.uri != *uri {
return true;
}
let Some(value_range) = &var.value_range else {
return true;
};
!range_contains(value_range, &occurrence.range)
})
.cloned()
.collect();
if replacements.is_empty() {
continue;
}
has_literal_colors = true;
let replacement_data: Vec<_> = replacements
.iter()
.map(|var| {
serde_json::json!({
"name": var.name,
"value": var.value,
"uri": var.uri,
})
})
.collect();
let replacement_count = replacement_data.len();
let message = if replacement_count == 1 {
format!("Consider using {} for this color", replacements[0].name)
} else {
let var_names: Vec<&str> = replacements.iter().map(|v| v.name.as_str()).collect();
format!(
"Consider using one of these variables: {}",
var_names.join(", ")
)
};
diagnostics.push(Diagnostic {
range: occurrence.range,
severity: Some(DiagnosticSeverity::INFORMATION),
code: Some(ls_types::NumberOrString::String(
"css-variable-lsp.literal-color-replaceable".to_string(),
)),
code_description: None,
source: Some("css-variable-lsp".to_string()),
message,
related_information: None,
tags: None,
data: Some(serde_json::json!({
"literal": occurrence.text,
"usageContext": occurrence.usage_context,
"replacements": replacement_data,
})),
});
}
{
let mut usage_map = document_usage_map.write().await;
let old_usages = usage_map.insert(uri.clone(), current_usages.clone());
let mut index = usage_index.write().await;
if let Some(old_set) = old_usages {
for name in old_set {
if !current_usages.contains(&name) {
if let Some(uris) = index.get_mut(&name) {
uris.remove(uri);
if uris.is_empty() {
index.remove(&name);
}
}
}
}
}
for name in current_usages {
index
.entry(name)
.or_insert_with(HashSet::new)
.insert(uri.clone());
}
}
if let Some(color_uris) = color_occurrence_uris {
let mut color_uris = color_uris.write().await;
if has_literal_colors {
color_uris.insert(uri.clone());
} else {
color_uris.remove(uri);
}
}
client
.publish_diagnostics(uri.clone(), diagnostics, None)
.await;
}
fn is_var_function_context_slice(before_cursor: &str) -> bool {
let bytes = before_cursor.as_bytes();
if bytes.is_empty() {
return false;
}
let mut i = bytes.len();
if is_word_byte(bytes[i - 1]) {
let mut start = i;
while start > 0 && is_word_byte(bytes[start - 1]) {
start -= 1;
}
if i - start < 2 || bytes[start] != b'-' || bytes[start + 1] != b'-' {
return false;
}
i = start;
}
while i > 0 && bytes[i - 1].is_ascii_whitespace() {
i -= 1;
}
if i == 0 || bytes[i - 1] != b'(' {
return false;
}
let paren_idx = i - 1;
if paren_idx < 3 {
return false;
}
let start = paren_idx - 3;
if !bytes[start..paren_idx].eq_ignore_ascii_case(b"var") {
return false;
}
if start == 0 {
return true;
}
!is_word_byte(bytes[start - 1])
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn test_word_extraction(css: &str, cursor_pos: usize) -> Option<String> {
use ls_types::Position;
let position = Position {
line: 0,
character: cursor_pos as u32,
};
let offset = position_to_offset(css, position)?;
let offset = clamp_to_char_boundary(css, offset);
let before = &css[..offset];
let after = &css[offset..];
let left = before
.rsplit(|c: char| !is_word_char(c))
.next()
.unwrap_or("");
let right = after.split(|c: char| !is_word_char(c)).next().unwrap_or("");
let word = format!("{}{}", left, right);
if word.starts_with("--") {
Some(word)
} else {
None
}
}
#[test]
fn test_word_extraction_preserves_fallbacks() {
let css = "background: var(--primary-color, blue);";
let result = test_word_extraction(css, 20); assert_eq!(result, Some("--primary-color".to_string()));
let css2 = "color: var(--secondary-color, #ccc);";
let result2 = test_word_extraction(css2, 15); assert_eq!(result2, Some("--secondary-color".to_string()));
let css3 = "border: var(--accent-color, var(--fallback, black));";
let result3 = test_word_extraction(css3, 16); assert_eq!(result3, Some("--accent-color".to_string()));
let css4 = "--theme-color: red;";
let result4 = test_word_extraction(css4, 5); assert_eq!(result4, Some("--theme-color".to_string()));
let css5 = "margin: var(--spacing);";
let result5 = test_word_extraction(css5, 15); assert_eq!(result5, Some("--spacing".to_string()));
}
#[test]
fn test_var_function_context_open() {
let text = "color: var(--primary";
assert!(is_var_function_context_slice(text));
}
#[test]
fn test_var_function_context_closed() {
let text = "color: var(--primary);";
assert!(!is_var_function_context_slice(text));
}
#[test]
fn test_var_function_context_nested() {
let text = "color: var(--primary, calc(100% - var(--secondary";
assert!(is_var_function_context_slice(text));
}
#[test]
fn test_var_function_context_after_fallback() {
let text = "color: var(--primary, ";
assert!(!is_var_function_context_slice(text));
}
#[test]
fn test_var_function_context_requires_boundary() {
let text = "navbar(--primary";
assert!(!is_var_function_context_slice(text));
}
#[test]
fn test_var_function_context_case_insensitive() {
let text = "color: VAR(--primary";
assert!(is_var_function_context_slice(text));
}
#[test]
fn test_completion_value_context_slice_css() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = ".card { color: var(";
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///styles.css").unwrap();
let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
.expect("expected css slice");
assert!(context.slice.ends_with("color: var("));
assert!(!context.allow_without_braces);
let value_context = get_value_context_info(context.slice, context.allow_without_braces);
assert!(value_context.is_value_context);
assert_eq!(value_context.property_name.as_deref(), Some("color"));
}
#[test]
fn test_completion_value_context_slice_html_style_attribute() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = r#"<div style="color: var("#;
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///index.html").unwrap();
let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
.expect("expected html style attribute slice");
assert_eq!(context.slice, "color: var(");
assert!(context.allow_without_braces);
}
#[test]
fn test_completion_value_context_slice_html_style_block() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = "<style>body { color: var(";
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///index.html").unwrap();
let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
.expect("expected html style block slice");
assert_eq!(context.slice, "body { color: var(");
assert!(!context.allow_without_braces);
}
#[test]
fn test_completion_value_context_slice_js_string() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = r#"const css = "color: var("#;
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///app.js").unwrap();
let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
.expect("expected js string slice");
assert_eq!(context.slice, "color: var(");
assert!(context.allow_without_braces);
}
#[test]
fn test_completion_value_context_slice_js_non_string() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = "const css = color: var(";
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///app.js").unwrap();
assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
}
#[test]
fn test_completion_value_context_slice_unknown() {
let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
let text = "color: var(";
let position = crate::types::offset_to_position(text, text.len());
let uri = Uri::from_str("file:///notes.txt").unwrap();
assert!(completion_value_context_slice(text, position, None, &uri, &lookup_map).is_none());
}
#[test]
fn test_html_style_attribute_slice_open() {
let text = r#"<div style="color: var("#;
let slice = find_html_style_attribute_slice(text).unwrap();
assert_eq!(slice, "color: var(");
}
#[test]
fn test_html_style_attribute_slice_closed() {
let text = r#"<div style="color: red">"#;
assert!(find_html_style_attribute_slice(text).is_none());
}
#[test]
fn test_html_style_block_slice() {
let text = "<style>body { color: var(";
let slice = find_html_style_block_slice(text).unwrap();
assert_eq!(slice, "body { color: var(");
}
#[test]
fn test_js_string_segment_basic() {
let text = r#"const css = \"color: var("#;
let slice = find_js_string_segment(text).unwrap();
assert_eq!(slice, "color: var(");
}
#[test]
fn test_js_string_segment_template_after_expression() {
let text = r#"const css = `color: ${theme}; background: var("#;
let slice = find_js_string_segment(text).unwrap();
assert_eq!(slice, "; background: var(");
}
#[test]
fn test_js_string_segment_template_expression() {
let text = r#"const css = `color: ${theme"#;
assert!(find_js_string_segment(text).is_none());
}
#[test]
fn resolve_document_kind_prefers_language_id() {
let lookup_files = vec!["**/*.custom".to_string()];
let lookup_map = build_lookup_extension_map(&lookup_files);
let kind = resolve_document_kind("file.custom", Some("html"), &lookup_map);
assert_eq!(kind, Some(DocumentKind::Html));
}
#[test]
fn resolve_document_kind_uses_lookup_extensions() {
let lookup_files = vec![
"**/*.{css,scss}".to_string(),
"**/*.vue".to_string(),
"**/*.custom".to_string(),
];
let lookup_map = build_lookup_extension_map(&lookup_files);
let css_kind = resolve_document_kind("styles.scss", None, &lookup_map);
assert_eq!(css_kind, Some(DocumentKind::Css));
let html_kind = resolve_document_kind("component.vue", None, &lookup_map);
assert_eq!(html_kind, Some(DocumentKind::Html));
let custom_kind = resolve_document_kind("theme.custom", None, &lookup_map);
assert_eq!(custom_kind, Some(DocumentKind::Css));
}
#[test]
fn resolve_document_kind_returns_none_for_unknown() {
let lookup_files = vec!["**/*.css".to_string()];
let lookup_map = build_lookup_extension_map(&lookup_files);
let kind = resolve_document_kind("notes.txt", Some("plaintext"), &lookup_map);
assert_eq!(kind, None);
}
}