Skip to main content

lattice_config_macros/
lib.rs

1//! Proc-macro front-end for `lattice-config`'s declarative
2//! option / group / overrides declarations (M.2.0+M.2.1, Design
3//! B + D from `docs/dev/architecture/mode-architecture.md` discussion notes).
4//!
5//! Three public macros:
6//!
7//! - `options!` — declares one or more typed options, each
8//!   bound to a registered `OptionGroup`. Supports
9//!   doc-comment capture, `#[aliases("...")]`,
10//!   `#[validate(fn_path)]`, `#[name("...")]` (explicit
11//!   display-name override), and `#[customizable(bool)]`.
12//!   Self-registers each option via `linkme::distributed_slice`
13//!   so the registry's pre-`main` boot walks the slice and
14//!   inserts every option without a central
15//!   `register_core_options` call.
16//!
17//! - `groups!` — declares one or more `OptionGroup` types.
18//!   Emits a compile-time `const fn` byte-walk assertion that
19//!   each group's display name does NOT end in `-mode` (the
20//!   modes-vs-groups disambiguation rule, `mode-architecture.md`
21//!   §6.7.1).
22//!
23//! - `overrides!` — constructs a typed `OptionOverrideSet`
24//!   from a list of `OptionDecl = value` pairs (M.2.1).
25//!   Compile-time-checks that each value matches its
26//!   declaration's `Value` type via a let-ascription
27//!   intermediate. Optional `#[priority(High)]` /
28//!   `#[priority(Low)]` attribute promotes individual entries.
29//!   Used by `Mode::options()` impls.
30//!
31//! Both macros emit code that references absolute paths into
32//! `::lattice_config`; the consumer crate must depend on
33//! `lattice-config`. The host crate (`lattice-config` itself)
34//! uses `extern crate self as lattice_config;` so the same
35//! absolute paths resolve in its own code.
36
37use proc_macro::TokenStream;
38use proc_macro2::TokenStream as TokenStream2;
39use quote::{format_ident, quote};
40use syn::{
41    Attribute, Expr, ExprLit, Ident, Lit, LitStr, Path, Token, Type,
42    parse::{Parse, ParseStream},
43    parse_macro_input,
44    punctuated::Punctuated,
45};
46
47// ---------------------------------------------------------
48// `options!` proc macro
49// ---------------------------------------------------------
50
51/// Declarative option declaration block. See the trait doc on
52/// `lattice_config::OptionDecl` and the macro doc on
53/// `lattice_config::options!` for the surface contract.
54#[proc_macro]
55pub fn options(input: TokenStream) -> TokenStream {
56    let parsed = parse_macro_input!(input as OptionsInput);
57    match expand_options(&parsed) {
58        Ok(ts) => ts.into(),
59        Err(e) => e.to_compile_error().into(),
60    }
61}
62
63struct OptionsInput {
64    namespace: Option<LitStr>,
65    group: Path,
66    decls: Vec<OptionDecl>,
67}
68
69struct OptionDecl {
70    attrs: Vec<Attribute>,
71    name: Ident,
72    ty: Type,
73    default: Expr,
74}
75
76impl Parse for OptionsInput {
77    fn parse(input: ParseStream) -> syn::Result<Self> {
78        // Optional `namespace = "..."`;
79        let namespace = if peek_keyword(input, "namespace") {
80            let _: Ident = input.parse()?;
81            let _: Token![=] = input.parse()?;
82            let s: LitStr = input.parse()?;
83            let _: Token![;] = input.parse()?;
84            Some(s)
85        } else {
86            None
87        };
88
89        // Required `group = path`;
90        let group_kw: Ident = input.parse()?;
91        if group_kw != "group" {
92            return Err(syn::Error::new(
93                group_kw.span(),
94                "expected `group = <path>;` directive",
95            ));
96        }
97        let _: Token![=] = input.parse()?;
98        let group: Path = input.parse()?;
99        let _: Token![;] = input.parse()?;
100
101        // Declarations until end of input.
102        let mut decls = Vec::new();
103        while !input.is_empty() {
104            let attrs = input.call(Attribute::parse_outer)?;
105            let _: Token![pub] = input.parse()?;
106            let name: Ident = input.parse()?;
107            let _: Token![:] = input.parse()?;
108            let ty: Type = input.parse()?;
109            let _: Token![=] = input.parse()?;
110            let default: Expr = input.parse()?;
111            let _: Token![;] = input.parse()?;
112            decls.push(OptionDecl {
113                attrs,
114                name,
115                ty,
116                default,
117            });
118        }
119
120        Ok(Self {
121            namespace,
122            group,
123            decls,
124        })
125    }
126}
127
128fn peek_keyword(input: ParseStream, name: &str) -> bool {
129    let fork = input.fork();
130    if let Ok(id) = fork.parse::<Ident>() {
131        id == name
132    } else {
133        false
134    }
135}
136
137fn expand_options(input: &OptionsInput) -> syn::Result<TokenStream2> {
138    let mut out = TokenStream2::new();
139    for decl in &input.decls {
140        out.extend(expand_one_option(
141            decl,
142            &input.group,
143            input.namespace.as_ref(),
144        )?);
145    }
146    Ok(out)
147}
148
149fn expand_one_option(
150    decl: &OptionDecl,
151    group: &Path,
152    namespace: Option<&LitStr>,
153) -> syn::Result<TokenStream2> {
154    let name_ident = &decl.name;
155    let ty = &decl.ty;
156    let default = &decl.default;
157
158    // Extract the option's facts from attribute list.
159    let facts = OptionFacts::extract(&decl.attrs)?;
160
161    // Compute display name.
162    let display_name = match &facts.explicit_name {
163        Some(s) => s.value(),
164        None => {
165            let kebab = camel_to_kebab(&name_ident.to_string());
166            match namespace {
167                Some(ns) => format!("{}.{}", ns.value(), kebab),
168                None => kebab,
169            }
170        }
171    };
172
173    let doc = facts.doc;
174    let aliases = &facts.aliases;
175    let validate = facts.validate.as_ref();
176    let customizable = facts.customizable;
177
178    // Build-spec function: constructs the runtime `Option<T>`
179    // spec by calling the existing builder API. The macro is
180    // a builder front-end (Design B from the design notes);
181    // the registration boot calls this and forwards to the
182    // registry's `register` path.
183    let aliases_lit: Vec<LitStr> = aliases
184        .iter()
185        .map(|s| LitStr::new(&s.value(), s.span()))
186        .collect();
187    let aliases_slice = if aliases_lit.is_empty() {
188        quote! { &[] as &[&'static str] }
189    } else {
190        quote! { &[#(#aliases_lit),*] }
191    };
192    let validate_chain = match validate {
193        Some(path) => quote! { .validate(#path) },
194        None => quote! {},
195    };
196
197    let display_name_lit = LitStr::new(&display_name, name_ident.span());
198    let doc_lit = LitStr::new(&doc, name_ident.span());
199
200    // A unique-per-option static identifier for the linkme
201    // submission. Using `format_ident!` keeps it inside the
202    // declaring crate's identifier space; collisions can't
203    // happen because the source identifier is itself unique
204    // in the local scope.
205    let link_name = format_ident!(
206        "__LATTICE_OPTION_DECL_{}",
207        uppercase(&name_ident.to_string())
208    );
209
210    Ok(quote! {
211        // ---- Type identity ----
212        #[doc = #doc_lit]
213        pub struct #name_ident;
214
215        impl ::lattice_config::OptionDecl for #name_ident {
216            type Value = #ty;
217            const NAME: &'static str = #display_name_lit;
218            const DOC: &'static str = #doc_lit;
219            const CUSTOMIZABLE: bool = #customizable;
220            fn default_value() -> Self::Value {
221                #default
222            }
223        }
224
225        impl ::lattice_config::HasGroup for #name_ident {
226            const GROUP_NAME: &'static str =
227                <#group as ::lattice_config::OptionGroup>::NAME;
228        }
229
230        impl #name_ident {
231            /// Build the runtime spec for this declaration.
232            /// Used by the registry's self-registration boot
233            /// path; not generally called by hand. Direct
234            /// construction of `Option<T>` is the macro's
235            /// internal-only path -- consumer code uses
236            /// `config.get_typed::<#name_ident>()` to read
237            /// and `config.set_typed::<#name_ident>(...)` to
238            /// write.
239            pub fn build_spec() -> ::lattice_config::option::Option<#ty> {
240                let b = ::lattice_config::option::Option::<#ty>::builder(
241                    #display_name_lit,
242                    #default,
243                    #doc_lit,
244                );
245                #[allow(clippy::let_and_return)]
246                let b = b.aliases(#aliases_slice);
247                let b = b #validate_chain;
248                b.build()
249            }
250        }
251
252        // ---- linkme submission for self-registration ----
253        //
254        // The `register_fn` thunk is called by the registry's
255        // `init_from_linkme` boot. It constructs the runtime
256        // spec via the macro-generated `build_spec()` and
257        // registers it with the typed-id mapping so type-keyed
258        // reads (`config.get_typed::<Self>()`) work post-boot.
259        const _: () = {
260            #[::lattice_config::linkme::distributed_slice(
261                ::lattice_config::OPTION_DECLS
262            )]
263            #[linkme(crate = ::lattice_config::linkme)]
264            static #link_name: &::lattice_config::OptionDeclMetadata =
265                &::lattice_config::OptionDeclMetadata::for_decl::<#name_ident>(
266                    <#ty as ::lattice_config::OptionType>::type_label,
267                    || <#ty as ::lattice_config::OptionType>::format(&#default),
268                    |registry: &::lattice_config::ConfigRegistry| {
269                        let _ = registry.register_with_typeid::<#ty>(
270                            <#name_ident>::build_spec(),
271                            ::std::any::TypeId::of::<#name_ident>(),
272                        );
273                    },
274                );
275        };
276    })
277}
278
279#[derive(Default)]
280struct OptionFacts {
281    doc: String,
282    aliases: Vec<LitStr>,
283    validate: Option<Path>,
284    explicit_name: Option<LitStr>,
285    customizable: bool,
286}
287
288impl OptionFacts {
289    fn extract(attrs: &[Attribute]) -> syn::Result<Self> {
290        let mut facts = Self {
291            customizable: true,
292            ..Self::default()
293        };
294        let mut doc_lines: Vec<String> = Vec::new();
295        for attr in attrs {
296            if attr.path().is_ident("doc") {
297                if let syn::Meta::NameValue(nv) = &attr.meta
298                    && let Expr::Lit(ExprLit {
299                        lit: Lit::Str(s), ..
300                    }) = &nv.value
301                {
302                    let line = s.value();
303                    let trimmed = line.strip_prefix(' ').unwrap_or(&line);
304                    doc_lines.push(trimmed.to_string());
305                }
306            } else if attr.path().is_ident("aliases") {
307                let parsed: Punctuated<LitStr, Token![,]> =
308                    attr.parse_args_with(Punctuated::parse_terminated)?;
309                for s in parsed {
310                    facts.aliases.push(s);
311                }
312            } else if attr.path().is_ident("validate") {
313                let p: Path = attr.parse_args()?;
314                facts.validate = Some(p);
315            } else if attr.path().is_ident("name") {
316                let s: LitStr = attr.parse_args()?;
317                facts.explicit_name = Some(s);
318            } else if attr.path().is_ident("customizable") {
319                let b: syn::LitBool = attr.parse_args()?;
320                facts.customizable = b.value;
321            }
322            // Other attributes pass through silently -- e.g.
323            // `#[cfg(...)]` on declarations is a future feature.
324        }
325        facts.doc = doc_lines.join("\n");
326        Ok(facts)
327    }
328}
329
330// ---------------------------------------------------------
331// `groups!` proc macro
332// ---------------------------------------------------------
333
334/// Declarative group declaration block. See
335/// `lattice_config::OptionGroup` and `lattice_config::groups!`
336/// for the surface contract.
337#[proc_macro]
338pub fn groups(input: TokenStream) -> TokenStream {
339    let parsed = parse_macro_input!(input as GroupsInput);
340    match expand_groups(&parsed) {
341        Ok(ts) => ts.into(),
342        Err(e) => e.to_compile_error().into(),
343    }
344}
345
346struct GroupsInput {
347    decls: Vec<GroupDecl>,
348}
349
350struct GroupDecl {
351    attrs: Vec<Attribute>,
352    name: Ident,
353    explicit_name: Option<LitStr>,
354}
355
356impl Parse for GroupsInput {
357    fn parse(input: ParseStream) -> syn::Result<Self> {
358        let mut decls = Vec::new();
359        while !input.is_empty() {
360            let attrs = input.call(Attribute::parse_outer)?;
361            let _: Token![pub] = input.parse()?;
362            let name: Ident = input.parse()?;
363            let explicit_name = if input.peek(Token![=]) {
364                let _: Token![=] = input.parse()?;
365                Some(input.parse::<LitStr>()?)
366            } else {
367                None
368            };
369            let _: Token![;] = input.parse()?;
370            decls.push(GroupDecl {
371                attrs,
372                name,
373                explicit_name,
374            });
375        }
376        Ok(Self { decls })
377    }
378}
379
380fn expand_groups(input: &GroupsInput) -> syn::Result<TokenStream2> {
381    let mut out = TokenStream2::new();
382    for decl in &input.decls {
383        out.extend(expand_one_group(decl)?);
384    }
385    Ok(out)
386}
387
388fn expand_one_group(decl: &GroupDecl) -> syn::Result<TokenStream2> {
389    let name_ident = &decl.name;
390    let display_name = match &decl.explicit_name {
391        Some(s) => s.value(),
392        None => camel_to_kebab(&name_ident.to_string()),
393    };
394
395    // Doc collection.
396    let mut doc_lines: Vec<String> = Vec::new();
397    for attr in &decl.attrs {
398        if attr.path().is_ident("doc")
399            && let syn::Meta::NameValue(nv) = &attr.meta
400            && let Expr::Lit(ExprLit {
401                lit: Lit::Str(s), ..
402            }) = &nv.value
403        {
404            let line = s.value();
405            let trimmed = line.strip_prefix(' ').unwrap_or(&line);
406            doc_lines.push(trimmed.to_string());
407        }
408    }
409    let doc = doc_lines.join("\n");
410
411    let display_name_lit = LitStr::new(&display_name, name_ident.span());
412    let doc_lit = LitStr::new(&doc, name_ident.span());
413    let link_name = format_ident!(
414        "__LATTICE_GROUP_DECL_{}",
415        uppercase(&name_ident.to_string())
416    );
417
418    Ok(quote! {
419        #[doc = #doc_lit]
420        pub struct #name_ident;
421
422        impl ::lattice_config::OptionGroup for #name_ident {
423            const NAME: &'static str = #display_name_lit;
424            const DOC: &'static str = #doc_lit;
425        }
426
427        // Compile-time assertion: name does NOT end in `-mode`.
428        const _: () = {
429            assert!(
430                !::lattice_config::ends_with_mode_suffix(
431                    <#name_ident as ::lattice_config::OptionGroup>::NAME
432                ),
433                "OptionGroup name must not end in `-mode` \
434                 (modes-vs-groups disambiguation; \
435                 `mode-architecture.md` §6.7.1)"
436            );
437        };
438
439        const _: () = {
440            #[::lattice_config::linkme::distributed_slice(
441                ::lattice_config::GROUP_DECLS
442            )]
443            #[linkme(crate = ::lattice_config::linkme)]
444            static #link_name: &::lattice_config::OptionGroupMetadata =
445                &::lattice_config::OptionGroupMetadata::for_group::<#name_ident>();
446        };
447    })
448}
449
450// ---------------------------------------------------------
451// `overrides!` proc macro
452// ---------------------------------------------------------
453
454/// Construct a typed `OptionOverrideSet` for a `Mode::options()`
455/// return.
456///
457/// Compile-time-typed: each entry is a path to an
458/// `OptionDecl` followed by `= value`. The macro emits a
459/// let-binding ascribed to `<#decl as OptionDecl>::Value` so
460/// values that don't match the declaration's `Value` type are
461/// compile errors. Optional `#[priority(High)]` /
462/// `#[priority(Low)]` attribute on an entry promotes it.
463///
464/// ```ignore
465/// fn options(&self) -> OptionOverrideSet {
466///     lattice_config::overrides! {
467///         Tabstop = 4,
468///         IndentStyle = IndentStyle::Spaces,
469///         #[priority(High)]
470///         ReadOnly = true,
471///     }
472/// }
473/// ```
474#[proc_macro]
475pub fn overrides(input: TokenStream) -> TokenStream {
476    let parsed = parse_macro_input!(input as OverridesInput);
477    match expand_overrides(&parsed) {
478        Ok(ts) => ts.into(),
479        Err(e) => e.to_compile_error().into(),
480    }
481}
482
483struct OverridesInput {
484    entries: Vec<OverrideEntry>,
485}
486
487struct OverrideEntry {
488    /// Optional `#[priority(...)]` attribute.
489    priority: Option<Ident>,
490    decl: Path,
491    value: Expr,
492}
493
494impl Parse for OverridesInput {
495    fn parse(input: ParseStream) -> syn::Result<Self> {
496        let mut entries = Vec::new();
497        while !input.is_empty() {
498            // Optional outer attributes (only `#[priority(...)]`
499            // is currently meaningful; others silently pass).
500            let attrs = input.call(Attribute::parse_outer)?;
501            let priority = parse_priority(&attrs)?;
502            let decl: Path = input.parse()?;
503            let _: Token![=] = input.parse()?;
504            let value: Expr = input.parse()?;
505            // Optional trailing comma.
506            if !input.is_empty() {
507                let _: Token![,] = input.parse()?;
508            }
509            entries.push(OverrideEntry {
510                priority,
511                decl,
512                value,
513            });
514        }
515        Ok(Self { entries })
516    }
517}
518
519fn parse_priority(attrs: &[Attribute]) -> syn::Result<Option<Ident>> {
520    for attr in attrs {
521        if attr.path().is_ident("priority") {
522            let ident: Ident = attr.parse_args()?;
523            // Validate the identifier is one we recognise.
524            if ident != "High" && ident != "Low" && ident != "Normal" {
525                return Err(syn::Error::new(
526                    ident.span(),
527                    "expected `High`, `Low`, or `Normal`",
528                ));
529            }
530            return Ok(Some(ident));
531        }
532    }
533    Ok(None)
534}
535
536fn expand_overrides(input: &OverridesInput) -> syn::Result<TokenStream2> {
537    let pushes: Vec<TokenStream2> = input
538        .entries
539        .iter()
540        .map(|entry| {
541            let decl = &entry.decl;
542            let value = &entry.value;
543            if let Some(priority) = &entry.priority {
544                quote! {
545                    {
546                        // Compile-time type check: the value
547                        // must coerce to the declaration's
548                        // Value type. Mismatches produce a
549                        // legible compile error at this site.
550                        let __value: <#decl as ::lattice_config::OptionDecl>::Value = #value;
551                        __set.push(
552                            ::lattice_config::OptionOverride::with_priority(
553                                ::std::any::TypeId::of::<#decl>(),
554                                __value,
555                                ::lattice_config::OverridePriority::#priority,
556                            )
557                        );
558                    }
559                }
560            } else {
561                quote! {
562                    {
563                        let __value: <#decl as ::lattice_config::OptionDecl>::Value = #value;
564                        __set.push(
565                            ::lattice_config::OptionOverride::new(
566                                ::std::any::TypeId::of::<#decl>(),
567                                __value,
568                            )
569                        );
570                    }
571                }
572            }
573        })
574        .collect();
575
576    Ok(quote! {{
577        let mut __set = ::lattice_config::OptionOverrideSet::new();
578        #(#pushes)*
579        __set
580    }})
581}
582
583// ---------------------------------------------------------
584// String helpers
585// ---------------------------------------------------------
586
587/// Convert a Rust identifier in PascalCase or camelCase to
588/// kebab-case. E.g. `Tabstop` → `tabstop`, `RelativeNumber`
589/// → `relative-number`, `CompletionGhostText` →
590/// `completion-ghost-text`.
591///
592/// The transformation is byte-level on ASCII identifiers
593/// (which Rust's identifier syntax permits beyond ASCII via
594/// XID_Start / XID_Continue, but the option-name namespace
595/// constrains us to ASCII anyway). Any character that's
596/// already lowercase / digit / hyphen passes through; an
597/// uppercase character introduces a hyphen separator before
598/// itself (unless at position 0) and is lowercased. Other
599/// characters (`_`) become hyphens.
600fn camel_to_kebab(s: &str) -> String {
601    let mut out = String::with_capacity(s.len() + 4);
602    for (i, c) in s.chars().enumerate() {
603        if c.is_ascii_uppercase() {
604            // Insert a separator before the uppercase letter
605            // unless we're at the start OR the previous output
606            // is already a hyphen (avoid `auto--insert` from
607            // `Auto_Insert`).
608            if i != 0 && !out.ends_with('-') {
609                out.push('-');
610            }
611            out.push(c.to_ascii_lowercase());
612        } else if c == '_' {
613            // Underscore-to-hyphen transformation; collapse if
614            // we just emitted one.
615            if !out.ends_with('-') {
616                out.push('-');
617            }
618        } else {
619            out.push(c);
620        }
621    }
622    out
623}
624
625fn uppercase(s: &str) -> String {
626    s.to_uppercase()
627}
628
629#[cfg(test)]
630mod tests {
631    use super::camel_to_kebab;
632
633    #[test]
634    fn kebab_basic() {
635        assert_eq!(camel_to_kebab("Tabstop"), "tabstop");
636        assert_eq!(camel_to_kebab("RelativeNumber"), "relative-number");
637        assert_eq!(
638            camel_to_kebab("CompletionGhostText"),
639            "completion-ghost-text"
640        );
641        assert_eq!(camel_to_kebab("Number"), "number");
642    }
643
644    #[test]
645    fn kebab_with_underscore() {
646        // Underscore-separated names are accepted (a stylistic
647        // alternative when PascalCase doesn't read well).
648        assert_eq!(camel_to_kebab("Auto_Insert"), "auto-insert");
649    }
650
651    #[test]
652    fn kebab_acronyms() {
653        // Acronyms (LSP, UI) lower to runs of single-char
654        // hyphenated segments. Acceptable trade-off for a
655        // simple transformation; explicit `#[name("lsp")]`
656        // overrides for the few sites that care.
657        assert_eq!(camel_to_kebab("LSP"), "l-s-p");
658        assert_eq!(camel_to_kebab("UIRender"), "u-i-render");
659    }
660}