use std::path::PathBuf;
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq)]
pub struct PluginSettings {
pub separate_diagnostic_server: bool,
pub publish_diagnostic_on: DiagnosticPublishMode,
pub tsserver: TsserverLaunchOptions,
pub tsserver_preferences: Map<String, Value>,
pub tsserver_format_options: Map<String, Value>,
pub enable_inlay_hints: bool,
}
impl Default for PluginSettings {
fn default() -> Self {
Self {
separate_diagnostic_server: true,
publish_diagnostic_on: DiagnosticPublishMode::InsertLeave,
tsserver: TsserverLaunchOptions::default(),
tsserver_preferences: Map::new(),
tsserver_format_options: Map::new(),
enable_inlay_hints: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticPublishMode {
InsertLeave,
Change,
}
impl DiagnosticPublishMode {
pub fn from_str(value: &str) -> Self {
match value {
"change" => Self::Change,
_ => Self::InsertLeave,
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Config {
plugin: PluginSettings,
}
impl Config {
pub fn new(plugin: PluginSettings) -> Self {
Self { plugin }
}
pub fn plugin_mut(&mut self) -> &mut PluginSettings {
&mut self.plugin
}
pub fn plugin(&self) -> &PluginSettings {
&self.plugin
}
pub fn apply_workspace_settings(&mut self, settings: &Value) -> bool {
apply_settings_tree(settings, &mut self.plugin)
}
}
fn apply_settings_tree(value: &Value, plugin: &mut PluginSettings) -> bool {
let mut changed = false;
if let Some(map) = value.as_object() {
changed |= plugin.update_from_map(map);
for key in POSSIBLE_SETTING_ROOTS {
if let Some(candidate) = map.get(*key) {
changed |= apply_settings_tree(candidate, plugin);
}
}
if let Some(plugin_section) = map.get("plugin") {
changed |= apply_settings_tree(plugin_section, plugin);
}
}
changed
}
const POSSIBLE_SETTING_ROOTS: &[&str] = &["ts-bridge", "tsBridge", "tsbridge", "ts_bridge"];
impl PluginSettings {
fn update_from_map(&mut self, map: &Map<String, Value>) -> bool {
let mut changed = false;
if let Some(value) = map
.get("separate_diagnostic_server")
.and_then(|v| v.as_bool())
{
if self.separate_diagnostic_server != value {
self.separate_diagnostic_server = value;
changed = true;
}
}
if let Some(value) = map.get("publish_diagnostic_on").and_then(|v| v.as_str()) {
let mode = DiagnosticPublishMode::from_str(value);
if self.publish_diagnostic_on != mode {
self.publish_diagnostic_on = mode;
changed = true;
}
}
if let Some(tsserver) = map.get("tsserver") {
changed |= self.tsserver.update_from_value(tsserver);
if let Some(tsserver_map) = tsserver.as_object() {
if tsserver_map.contains_key("preferences") {
let next = tsserver_map
.get("preferences")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
if self.tsserver_preferences != next {
self.tsserver_preferences = next;
changed = true;
}
}
let format_value = if tsserver_map.contains_key("format_options") {
tsserver_map.get("format_options")
} else if tsserver_map.contains_key("formatOptions") {
tsserver_map.get("formatOptions")
} else {
None
};
if let Some(value) = format_value {
let next = value.as_object().cloned().unwrap_or_default();
if self.tsserver_format_options != next {
self.tsserver_format_options = next;
changed = true;
}
}
}
}
if let Some(value) = map.get("enable_inlay_hints").and_then(|v| v.as_bool()) {
if self.enable_inlay_hints != value {
self.enable_inlay_hints = value;
changed = true;
}
}
changed
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TsserverLaunchOptions {
pub locale: Option<String>,
pub log_directory: Option<PathBuf>,
pub log_verbosity: Option<TsserverLogVerbosity>,
pub max_old_space_size: Option<u32>,
pub global_plugins: Vec<String>,
pub plugin_probe_dirs: Vec<PathBuf>,
pub extra_args: Vec<String>,
}
impl TsserverLaunchOptions {
fn update_from_value(&mut self, value: &Value) -> bool {
let map = match value.as_object() {
Some(map) => map,
None => return false,
};
let mut changed = false;
if map.contains_key("locale") {
let next = map
.get("locale")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if self.locale != next {
self.locale = next;
changed = true;
}
}
if map.contains_key("log_directory") {
let next = map
.get("log_directory")
.and_then(|v| v.as_str())
.map(PathBuf::from);
if self.log_directory != next {
self.log_directory = next;
changed = true;
}
}
if map.contains_key("log_verbosity") {
let next = map
.get("log_verbosity")
.and_then(|v| v.as_str())
.and_then(TsserverLogVerbosity::from_str);
if self.log_verbosity != next {
self.log_verbosity = next;
changed = true;
}
}
if map.contains_key("max_old_space_size") {
let next = map
.get("max_old_space_size")
.and_then(|v| v.as_u64())
.and_then(|v| v.try_into().ok());
if self.max_old_space_size != next {
self.max_old_space_size = next;
changed = true;
}
}
if let Some(list) = map
.get("global_plugins")
.and_then(|value| string_list(value))
{
if self.global_plugins != list {
self.global_plugins = list;
changed = true;
}
}
if let Some(list) = map
.get("plugin_probe_dirs")
.and_then(|value| string_list(value))
.map(|entries| entries.into_iter().map(PathBuf::from).collect::<Vec<_>>())
{
if self.plugin_probe_dirs != list {
self.plugin_probe_dirs = list;
changed = true;
}
}
if let Some(list) = map.get("extra_args").and_then(|value| string_list(value)) {
if self.extra_args != list {
self.extra_args = list;
changed = true;
}
}
changed
}
}
fn string_list(value: &Value) -> Option<Vec<String>> {
let array = value.as_array()?;
let mut result = Vec::with_capacity(array.len());
for entry in array {
let Some(text) = entry.as_str() else {
continue;
};
result.push(text.to_string());
}
Some(result)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TsserverLogVerbosity {
Terse,
Normal,
RequestTime,
Verbose,
}
impl TsserverLogVerbosity {
pub fn from_str(value: &str) -> Option<Self> {
match value {
"terse" => Some(Self::Terse),
"normal" => Some(Self::Normal),
"requestTime" | "request_time" => Some(Self::RequestTime),
"verbose" => Some(Self::Verbose),
_ => None,
}
}
pub fn as_cli_flag(&self) -> &'static str {
match self {
Self::Terse => "terse",
Self::Normal => "normal",
Self::RequestTime => "requestTime",
Self::Verbose => "verbose",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn apply_workspace_settings_updates_tsserver_preferences_and_format_options() {
let mut config = Config::new(PluginSettings::default());
let settings = json!({
"ts-bridge": {
"tsserver": {
"preferences": {
"importModuleSpecifierPreference": "relative"
},
"format_options": {
"indentSize": 4
}
}
}
});
let changed = config.apply_workspace_settings(&settings);
assert!(changed);
assert_eq!(
config
.plugin()
.tsserver_preferences
.get("importModuleSpecifierPreference")
.and_then(|value| value.as_str()),
Some("relative")
);
assert_eq!(
config
.plugin()
.tsserver_format_options
.get("indentSize")
.and_then(|value| value.as_i64()),
Some(4)
);
}
#[test]
fn apply_workspace_settings_accepts_format_options_camel_case() {
let mut config = Config::new(PluginSettings::default());
let settings = json!({
"ts-bridge": {
"tsserver": {
"formatOptions": {
"tabSize": 2
}
}
}
});
let changed = config.apply_workspace_settings(&settings);
assert!(changed);
assert_eq!(
config
.plugin()
.tsserver_format_options
.get("tabSize")
.and_then(|value| value.as_i64()),
Some(2)
);
}
}