From 0cb8a8983cef1f3e015fa0f2fc37e8325f3d201d Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:30:48 -0400 Subject: [PATCH] settings ui: Improve setting proc macro and add scroll to UI (#37581) This PR improves the settings_ui proc macro by taking into account more serde attributes 1. rename_all 2. rename 3. flatten We also pass field documentation to the UI layer now too. This allows ui elements to have more information like the switch field description. We got the scrollbar working and started getting language settings to show up. Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- crates/editor/src/editor_settings.rs | 1 + crates/language/src/language_settings.rs | 38 ++-- crates/settings/src/settings_ui_core.rs | 9 + crates/settings_ui/src/settings_ui.rs | 123 ++++++++----- .../src/settings_ui_macros.rs | 170 +++++++++++++++--- 5 files changed, 258 insertions(+), 83 deletions(-) diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index d74244131e6635c7b9eda6ace0723ced96b0e041..7f4d024e57c4831aa4c512e6dcb3a9ab35d4f610 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -748,6 +748,7 @@ pub struct ScrollbarAxesContent { #[derive( Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi, )] +#[settings_ui(group = "Gutter")] pub struct GutterContent { /// Whether to show line numbers in the gutter. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 3443ccf592a4138edb61959f0dd82bdb8cc8d418..cb519e32eca964cee4a742085714b233a424dd3c 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -208,7 +208,9 @@ impl LanguageSettings { } /// The provider that supplies edit predictions. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive( + Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] #[serde(rename_all = "snake_case")] pub enum EditPredictionProvider { None, @@ -231,13 +233,14 @@ impl EditPredictionProvider { /// The settings for edit predictions, such as [GitHub Copilot](https://github.com/features/copilot) /// or [Supermaven](https://supermaven.com). -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, SettingsUi)] pub struct EditPredictionSettings { /// The provider that supplies edit predictions. pub provider: EditPredictionProvider, /// A list of globs representing files that edit predictions should be disabled for. /// This list adds to a pre-existing, sensible default set of globs. /// Any additional ones you add are combined with them. + #[settings_ui(skip)] pub disabled_globs: Vec, /// Configures how edit predictions are displayed in the buffer. pub mode: EditPredictionsMode, @@ -269,7 +272,9 @@ pub struct DisabledGlob { } /// The mode in which edit predictions should be displayed. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive( + Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] #[serde(rename_all = "snake_case")] pub enum EditPredictionsMode { /// If provider supports it, display inline when holding modifier key (e.g., alt). @@ -282,13 +287,15 @@ pub enum EditPredictionsMode { Eager, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, SettingsUi)] pub struct CopilotSettings { /// HTTP/HTTPS proxy to use for Copilot. + #[settings_ui(skip)] pub proxy: Option, /// Disable certificate verification for proxy (not recommended). pub proxy_no_verify: Option, /// Enterprise URI for Copilot. + #[settings_ui(skip)] pub enterprise_uri: Option, } @@ -297,6 +304,7 @@ pub struct CopilotSettings { Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, SettingsKey, )] #[settings_key(None)] +#[settings_ui(group = "Default Language Settings")] pub struct AllLanguageSettingsContent { /// The settings for enabling/disabling features. #[serde(default)] @@ -309,10 +317,12 @@ pub struct AllLanguageSettingsContent { pub defaults: LanguageSettingsContent, /// The settings for individual languages. #[serde(default)] + #[settings_ui(skip)] pub languages: LanguageToSettingsMap, /// Settings for associating file extensions and filenames /// with languages. #[serde(default)] + #[settings_ui(skip)] pub file_types: HashMap, Vec>, } @@ -345,7 +355,7 @@ inventory::submit! { } /// Controls how completions are processed for this language. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] pub struct CompletionSettings { /// Controls how words are completed. @@ -420,7 +430,7 @@ fn default_3() -> usize { } /// The settings for a particular language. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi)] pub struct LanguageSettingsContent { /// How many columns a tab should occupy. /// @@ -617,12 +627,13 @@ pub enum RewrapBehavior { } /// The contents of the edit prediction settings. -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi)] pub struct EditPredictionSettingsContent { /// A list of globs representing files that edit predictions should be disabled for. /// This list adds to a pre-existing, sensible default set of globs. /// Any additional ones you add are combined with them. #[serde(default)] + #[settings_ui(skip)] pub disabled_globs: Option>, /// The mode used to display edit predictions in the buffer. /// Provider support required. @@ -637,12 +648,13 @@ pub struct EditPredictionSettingsContent { pub enabled_in_text_threads: bool, } -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi)] pub struct CopilotSettingsContent { /// HTTP/HTTPS proxy to use for Copilot. /// /// Default: none #[serde(default)] + #[settings_ui(skip)] pub proxy: Option, /// Disable certificate verification for the proxy (not recommended). /// @@ -653,19 +665,21 @@ pub struct CopilotSettingsContent { /// /// Default: none #[serde(default)] + #[settings_ui(skip)] pub enterprise_uri: Option, } /// The settings for enabling/disabling features. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] +#[settings_ui(group = "Features")] pub struct FeaturesContent { /// Determines which edit prediction provider to use. pub edit_prediction_provider: Option, } /// Controls the soft-wrapping behavior in the editor. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] pub enum SoftWrap { /// Prefer a single line generally, unless an overly long line is encountered. @@ -934,7 +948,9 @@ pub enum Formatter { } /// The settings for indent guides. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive( + Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] pub struct IndentGuideSettings { /// Whether to display indent guides in the editor. /// diff --git a/crates/settings/src/settings_ui_core.rs b/crates/settings/src/settings_ui_core.rs index 9086d3c7454465e8abcaf2d30d01a4f928e4ddef..896a8bc038bdd8e495cdb6161212f9c722d54f14 100644 --- a/crates/settings/src/settings_ui_core.rs +++ b/crates/settings/src/settings_ui_core.rs @@ -19,6 +19,7 @@ pub trait SettingsUi { path: None, title: "None entry", item: SettingsUiItem::None, + documentation: None, } } } @@ -29,6 +30,8 @@ pub struct SettingsUiEntry { pub path: Option<&'static str>, /// What is displayed for the text for this entry pub title: &'static str, + /// documentation for this entry. Constructed from the documentation comment above the struct or field + pub documentation: Option<&'static str>, pub item: SettingsUiItem, } @@ -54,6 +57,7 @@ pub enum SettingsUiItemSingle { pub struct SettingsValue { pub title: &'static str, + pub documentation: Option<&'static str>, pub path: SmallVec<[&'static str; 1]>, pub value: Option, pub default_value: T, @@ -128,7 +132,9 @@ pub enum NumType { U64 = 0, U32 = 1, F32 = 2, + USIZE = 3, } + pub static NUM_TYPE_NAMES: std::sync::LazyLock<[&'static str; NumType::COUNT]> = std::sync::LazyLock::new(|| NumType::ALL.map(NumType::type_name)); pub static NUM_TYPE_IDS: std::sync::LazyLock<[TypeId; NumType::COUNT]> = @@ -143,6 +149,7 @@ impl NumType { NumType::U64 => TypeId::of::(), NumType::U32 => TypeId::of::(), NumType::F32 => TypeId::of::(), + NumType::USIZE => TypeId::of::(), } } @@ -151,6 +158,7 @@ impl NumType { NumType::U64 => std::any::type_name::(), NumType::U32 => std::any::type_name::(), NumType::F32 => std::any::type_name::(), + NumType::USIZE => std::any::type_name::(), } } } @@ -175,3 +183,4 @@ numeric_stepper_for_num_type!(u64, U64); numeric_stepper_for_num_type!(u32, U32); // todo(settings_ui) is there a better ui for f32? numeric_stepper_for_num_type!(f32, F32); +numeric_stepper_for_num_type!(usize, USIZE); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f316a318785c7f56d465c2d39e6b6ea9bbbd1bfa..d736f0e174ba13d368794d8f5b623a44845d561b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1,14 +1,13 @@ mod appearance_settings_controls; use std::any::TypeId; -use std::collections::VecDeque; use std::ops::{Not, Range}; use anyhow::Context as _; use command_palette_hooks::CommandPaletteFilter; use editor::EditorSettingsControls; use feature_flags::{FeatureFlag, FeatureFlagViewExt}; -use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, actions}; +use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions}; use settings::{ NumType, SettingsStore, SettingsUiEntry, SettingsUiItem, SettingsUiItemDynamic, SettingsUiItemGroup, SettingsUiItemSingle, SettingsValue, @@ -138,6 +137,7 @@ impl Item for SettingsPage { struct UiEntry { title: &'static str, path: Option<&'static str>, + documentation: Option<&'static str>, _depth: usize, // a // b < a descendant range < a total descendant range @@ -195,6 +195,7 @@ fn build_tree_item( tree.push(UiEntry { title: entry.title, path: entry.path, + documentation: entry.documentation, _depth: depth, descendant_range: index + 1..index + 1, total_descendant_range: index + 1..index + 1, @@ -354,32 +355,29 @@ fn render_content( tree: &SettingsUiTree, window: &mut Window, cx: &mut Context, -) -> impl IntoElement { - let Some(active_entry) = tree.entries.get(tree.active_entry_index) else { - return div() - .size_full() - .child(Label::new(SharedString::new_static("No settings found")).color(Color::Error)); - }; - let mut content = v_flex().size_full().gap_4().overflow_hidden(); +) -> Div { + let content = v_flex().size_full().gap_4(); let mut path = smallvec::smallvec![]; - if let Some(active_entry_path) = active_entry.path { - path.push(active_entry_path); - } - let mut entry_index_queue = VecDeque::new(); - - if let Some(child_index) = active_entry.first_descendant_index() { - entry_index_queue.push_back(child_index); - let mut index = child_index; - while let Some(next_sibling_index) = tree.entries[index].next_sibling { - entry_index_queue.push_back(next_sibling_index); - index = next_sibling_index; - } - }; - while let Some(index) = entry_index_queue.pop_front() { + fn render_recursive( + tree: &SettingsUiTree, + index: usize, + path: &mut SmallVec<[&'static str; 1]>, + mut element: Div, + window: &mut Window, + cx: &mut App, + ) -> Div { + let Some(child) = tree.entries.get(index) else { + return element.child( + Label::new(SharedString::new_static("No settings found")).color(Color::Error), + ); + }; + + element = + element.child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large)); + // todo(settings_ui): subgroups? - let child = &tree.entries[index]; let mut pushed_path = false; if let Some(child_path) = child.path { path.push(child_path); @@ -388,37 +386,56 @@ fn render_content( let settings_value = settings_value_from_settings_and_path( path.clone(), child.title, + child.documentation, // PERF: how to structure this better? There feels like there's a way to avoid the clone // and every value lookup SettingsStore::global(cx).raw_user_settings(), SettingsStore::global(cx).raw_default_settings(), ); if let Some(select_descendant) = child.select_descendant { - let selected_descendant = select_descendant(settings_value.read(), cx); - if let Some(descendant_index) = - child.nth_descendant_index(&tree.entries, selected_descendant) - { - entry_index_queue.push_front(descendant_index); + let selected_descendant = child + .nth_descendant_index(&tree.entries, select_descendant(settings_value.read(), cx)); + if let Some(descendant_index) = selected_descendant { + element = render_recursive(&tree, descendant_index, path, element, window, cx); } } + if let Some(child_render) = child.render.as_ref() { + element = element.child(div().child(render_item_single( + settings_value, + child_render, + window, + cx, + ))); + } else if let Some(child_index) = child.first_descendant_index() { + let mut index = Some(child_index); + while let Some(sub_child_index) = index { + element = render_recursive(tree, sub_child_index, path, element, window, cx); + index = tree.entries[sub_child_index].next_sibling; + } + } else { + element = + element.child(div().child(Label::new("// skipped (for now)").color(Color::Muted))) + } + if pushed_path { path.pop(); } - let Some(child_render) = child.render.as_ref() else { - continue; - }; - content = content.child( - div() - .child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large)) - .child(render_item_single(settings_value, child_render, window, cx)), - ); + return element; } - return content; + return render_recursive( + tree, + tree.active_entry_index, + &mut path, + content, + window, + cx, + ); } impl Render for SettingsPage { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scroll_handle = window.use_state(cx, |_, _| ScrollHandle::new()); div() .grid() .grid_cols(16) @@ -427,15 +444,19 @@ impl Render for SettingsPage { .size_full() .child( div() + .id("settings-ui-nav") .col_span(2) .h_full() .child(render_nav(&self.settings_tree, window, cx)), ) - .child(div().col_span(4).h_full().child(render_content( - &self.settings_tree, - window, - cx, - ))) + .child( + div().col_span(6).h_full().child( + render_content(&self.settings_tree, window, cx) + .id("settings-ui-content") + .track_scroll(scroll_handle.read(cx)) + .overflow_y_scroll(), + ), + ) } } @@ -530,6 +551,7 @@ fn downcast_any_item( let deserialized_setting_value = SettingsValue { title: settings_value.title, path: settings_value.path, + documentation: settings_value.documentation, value, default_value, }; @@ -586,6 +608,17 @@ fn render_any_numeric_stepper( window, cx, ), + NumType::USIZE => render_numeric_stepper::( + downcast_any_item(settings_value), + usize::saturating_sub, + usize::saturating_add, + |n| { + serde_json::Number::try_from(n) + .context("Failed to convert usize to serde_json::Number") + }, + window, + cx, + ), } } @@ -640,7 +673,7 @@ fn render_switch_field( SwitchField::new( id, SharedString::new_static(value.title), - None, + value.documentation.map(SharedString::new_static), match value.read() { true => ToggleState::Selected, false => ToggleState::Unselected, @@ -731,6 +764,7 @@ fn render_toggle_button_group( fn settings_value_from_settings_and_path( path: SmallVec<[&'static str; 1]>, title: &'static str, + documentation: Option<&'static str>, user_settings: &serde_json::Value, default_settings: &serde_json::Value, ) -> SettingsValue { @@ -743,6 +777,7 @@ fn settings_value_from_settings_and_path( let settings_value = SettingsValue { default_value, value, + documentation, path: path.clone(), // todo(settings_ui) is title required inside SettingsValue? title, diff --git a/crates/settings_ui_macros/src/settings_ui_macros.rs b/crates/settings_ui_macros/src/settings_ui_macros.rs index 1895083508a6a606f4dd9889529719aa12ea0b10..076f9c0f04e2963e9f4732a1fc7177f9ab85c723 100644 --- a/crates/settings_ui_macros/src/settings_ui_macros.rs +++ b/crates/settings_ui_macros/src/settings_ui_macros.rs @@ -1,3 +1,5 @@ +use std::ops::Not; + use heck::{ToSnakeCase as _, ToTitleCase as _}; use proc_macro2::TokenStream; use quote::{ToTokens, quote}; @@ -63,12 +65,19 @@ pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenSt } } + let doc_str = parse_documentation_from_attrs(&input.attrs); + let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input); // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title let title = group_name.unwrap_or(input.ident.to_string().to_title_case()); - let ui_entry_fn_body = map_ui_item_to_entry(path_name.as_deref(), &title, quote! { Self }); + let ui_entry_fn_body = map_ui_item_to_entry( + path_name.as_deref(), + &title, + doc_str.as_deref(), + quote! { Self }, + ); let expanded = quote! { impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause { @@ -111,14 +120,22 @@ fn option_inner_type(ty: TokenStream) -> Option { return Some(ty.to_token_stream()); } -fn map_ui_item_to_entry(path: Option<&str>, title: &str, ty: TokenStream) -> TokenStream { +fn map_ui_item_to_entry( + path: Option<&str>, + title: &str, + doc_str: Option<&str>, + ty: TokenStream, +) -> TokenStream { let ty = extract_type_from_option(ty); + // todo(settings_ui): does quote! just work with options? let path = path.map_or_else(|| quote! {None}, |path| quote! {Some(#path)}); + let doc_str = doc_str.map_or_else(|| quote! {None}, |doc_str| quote! {Some(#doc_str)}); quote! { settings::SettingsUiEntry { title: #title, path: #path, item: #ty::settings_ui_item(), + documentation: #doc_str, } } } @@ -134,6 +151,7 @@ fn generate_ui_item_body( settings::SettingsUiItem::None }, (Some(_), _, Data::Struct(data_struct)) => { + let struct_serde_attrs = parse_serde_attributes(&input.attrs); let fields = data_struct .fields .iter() @@ -153,48 +171,37 @@ fn generate_ui_item_body( }) }) .map(|field| { + let field_serde_attrs = parse_serde_attributes(&field.attrs); + let name = field.ident.clone().expect("tuple fields").to_string(); + let doc_str = parse_documentation_from_attrs(&field.attrs); + ( - field.ident.clone().expect("tuple fields").to_string(), + name.to_title_case(), + doc_str, + field_serde_attrs.flatten.not().then(|| { + struct_serde_attrs.apply_rename_to_field(&field_serde_attrs, &name) + }), field.ty.to_token_stream(), ) }) // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr - .map(|(name, ty)| map_ui_item_to_entry(Some(&name), &name.to_title_case(), ty)); + .map(|(title, doc_str, path, ty)| { + map_ui_item_to_entry(path.as_deref(), &title, doc_str.as_deref(), ty) + }); quote! { settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#fields),*] }) } } (None, _, Data::Enum(data_enum)) => { - let mut lowercase = false; - let mut snake_case = false; - for attr in &input.attrs { - if attr.path().is_ident("serde") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") { - meta.input.parse::()?; - let lit = meta.input.parse::()?.value(); - lowercase = lit == "lowercase"; - snake_case = lit == "snake_case"; - } - Ok(()) - }) - .ok(); - } - } + let serde_attrs = parse_serde_attributes(&input.attrs); let length = data_enum.variants.len(); let variants = data_enum.variants.iter().map(|variant| { let string = variant.ident.clone().to_string(); let title = string.to_title_case(); - let string = if lowercase { - string.to_lowercase() - } else if snake_case { - string.to_snake_case() - } else { - string - }; + let string = serde_attrs.rename_all.apply(&string); (string, title) }); @@ -218,6 +225,113 @@ fn generate_ui_item_body( } } +struct SerdeOptions { + rename_all: SerdeRenameAll, + rename: Option, + flatten: bool, + _alias: Option, // todo(settings_ui) +} + +#[derive(PartialEq)] +enum SerdeRenameAll { + Lowercase, + SnakeCase, + None, +} + +impl SerdeRenameAll { + fn apply(&self, name: &str) -> String { + match self { + SerdeRenameAll::Lowercase => name.to_lowercase(), + SerdeRenameAll::SnakeCase => name.to_snake_case(), + SerdeRenameAll::None => name.to_string(), + } + } +} + +impl SerdeOptions { + fn apply_rename_to_field(&self, field_options: &Self, name: &str) -> String { + // field renames take precedence over struct rename all cases + if let Some(rename) = &field_options.rename { + return rename.clone(); + } + return self.rename_all.apply(name); + } +} + +fn parse_serde_attributes(attrs: &[syn::Attribute]) -> SerdeOptions { + let mut options = SerdeOptions { + rename_all: SerdeRenameAll::None, + rename: None, + flatten: false, + _alias: None, + }; + + for attr in attrs { + if !attr.path().is_ident("serde") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + meta.input.parse::()?; + let lit = meta.input.parse::()?.value(); + + if options.rename_all != SerdeRenameAll::None { + return Err(meta.error("duplicate `rename_all` attribute")); + } else if lit == "lowercase" { + options.rename_all = SerdeRenameAll::Lowercase; + } else if lit == "snake_case" { + options.rename_all = SerdeRenameAll::SnakeCase; + } else { + return Err(meta.error(format!("invalid `rename_all` attribute: {}", lit))); + } + // todo(settings_ui): Other options? + } else if meta.path.is_ident("flatten") { + options.flatten = true; + } else if meta.path.is_ident("rename") { + if options.rename.is_some() { + return Err(meta.error("Can only have one rename attribute")); + } + + meta.input.parse::()?; + let lit = meta.input.parse::()?.value(); + options.rename = Some(lit); + } + Ok(()) + }) + .unwrap(); + } + + return options; +} + +fn parse_documentation_from_attrs(attrs: &[syn::Attribute]) -> Option { + let mut doc_str = Option::::None; + for attr in attrs { + if attr.path().is_ident("doc") { + // /// ... + // becomes + // #[doc = "..."] + use syn::{Expr::Lit, ExprLit, Lit::Str, Meta, MetaNameValue}; + if let Meta::NameValue(MetaNameValue { + value: + Lit(ExprLit { + lit: Str(ref lit_str), + .. + }), + .. + }) = attr.meta + { + let doc = lit_str.value(); + let doc_str = doc_str.get_or_insert_default(); + doc_str.push_str(doc.trim()); + doc_str.push('\n'); + } + } + } + return doc_str; +} + struct SettingsKey { key: Option, fallback_key: Option, @@ -290,7 +404,7 @@ pub fn derive_settings_key(input: proc_macro::TokenStream) -> proc_macro::TokenS if parsed_settings_key.is_some() && settings_key.is_some() { panic!("Duplicate #[settings_key] attribute"); } - settings_key = parsed_settings_key; + settings_key = settings_key.or(parsed_settings_key); } let Some(SettingsKey { key, fallback_key }) = settings_key else {