From 0565624599ed5539a2b333edeef9477824b5e7b9 Mon Sep 17 00:00:00 2001 From: J / Jacob Babich Date: Thu, 9 Jul 2026 22:20:24 -0400 Subject: [PATCH] feat: implement `StringLiteral` macro --- Cargo.lock | 60 ++++++++++++- Cargo.toml | 2 + home-assistant/Cargo.toml | 1 + .../src/event/specific/state_changed.rs | 31 +------ .../src/sensor/device_classes/power.rs | 49 ++--------- .../src/sensor/state_classes/measurement.rs | 50 +---------- string-literal-macros/Cargo.toml | 14 +++ string-literal-macros/src/lib.rs | 88 +++++++++++++++++++ string-literal/Cargo.toml | 11 +++ string-literal/src/lib.rs | 24 +++++ 10 files changed, 209 insertions(+), 121 deletions(-) create mode 100644 string-literal-macros/Cargo.toml create mode 100644 string-literal-macros/src/lib.rs create mode 100644 string-literal/Cargo.toml create mode 100644 string-literal/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3f56119..ea46f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,8 +428,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -446,13 +456,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -885,6 +919,7 @@ dependencies = [ "pyo3-async-runtimes", "python-utils", "snafu", + "string-literal", "strum", "tokio", "tracing", @@ -2046,7 +2081,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -2188,6 +2223,23 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ae9eec00137a8eed469fb4148acd9fc6ac8c3f9b110f52cd34698c8b5bfa0e" +[[package]] +name = "string-literal" +version = "0.1.0" +dependencies = [ + "string-literal-macros", +] + +[[package]] +name = "string-literal-macros" +version = "0.1.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 41eb436..fdc2896 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ members = [ "python-utils", "python-utils-macros", "python-utils-macros-impl", + "string-literal", + "string-literal-macros", ] resolver = "2" diff --git a/home-assistant/Cargo.toml b/home-assistant/Cargo.toml index 762c0ea..33307f5 100644 --- a/home-assistant/Cargo.toml +++ b/home-assistant/Cargo.toml @@ -26,6 +26,7 @@ pyo3 = { workspace = true } pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] } python-utils = { path = "../python-utils", features = ["macros"] } snafu = { workspace = true } +string-literal = { path = "../string-literal", features = ["macros"] } strum = { workspace = true, features = ["derive"] } tokio = { workspace = true } tracing = { optional = true, workspace = true } diff --git a/home-assistant/src/event/specific/state_changed.rs b/home-assistant/src/event/specific/state_changed.rs index c4d14ff..88851fb 100644 --- a/home-assistant/src/event/specific/state_changed.rs +++ b/home-assistant/src/event/specific/state_changed.rs @@ -4,39 +4,14 @@ use std::str::FromStr; use pyo3::FromPyObject; use python_utils::{FromPyFromStr, ToStrToPy}; use snafu::Snafu; +use string_literal::StringLiteral; use crate::{entity_id::EntityId, state_object::StateObject}; -// TODO: replace with a derive(PyFromStrLiteral) / #[literal = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration -#[derive(Debug, Clone, FromPyFromStr, ToStrToPy)] +#[derive(Debug, Clone, StringLiteral, FromPyFromStr, ToStrToPy)] +#[string_literal(value = "state_changed")] pub struct Type; -/// expected a string of value "state_changed", but got {actual} -#[derive(Debug, Snafu)] -pub struct ParseTypeError { - actual: String, -} - -impl FromStr for Type { - type Err = ParseTypeError; - - fn from_str(s: &str) -> Result { - if s == "state_changed" { - Ok(Self) - } else { - Err(ParseTypeError { - actual: s.to_owned(), - }) - } - } -} - -impl Display for Type { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "state_changed") - } -} - #[derive(Debug, FromPyObject)] #[pyo3(from_item_all)] pub struct Data< diff --git a/home-assistant/src/sensor/device_classes/power.rs b/home-assistant/src/sensor/device_classes/power.rs index 5be534c..fd51099 100644 --- a/home-assistant/src/sensor/device_classes/power.rs +++ b/home-assistant/src/sensor/device_classes/power.rs @@ -1,12 +1,10 @@ use std::{future::Future, sync::Arc}; use emitter_and_signal::{Signal, SignalExt}; -use pyo3::{ - exceptions::{PyException, PyValueError}, - prelude::*, -}; -use python_utils::FromPyObjectViaParse; -use snafu::{ensure, ResultExt, Snafu}; +use pyo3::{FromPyObject, Py, PyAny, PyErr, Python}; +use python_utils::{FromPyFromStr, FromPyObjectViaParse, ToStrToPy}; +use snafu::{ResultExt, Snafu}; +use string_literal::StringLiteral; use super::super::state_classes::measurement::Measurement; use crate::{ @@ -18,45 +16,10 @@ use crate::{ unit_of_measurement::power::UnitOfMeasurement, }; -#[derive(Debug)] +#[derive(Debug, Clone, Copy, StringLiteral, FromPyFromStr, ToStrToPy)] +#[string_literal(value = "power")] struct Power; -#[derive(Debug, Snafu)] -pub enum ExtractPowerError { - /// couldn't extract the object as a string - ExtractStringError { source: PyErr }, - - /// the string {actual:?} is not "power" like it's supposed to be - NotPower { actual: String }, -} - -impl From for PyErr { - fn from(error: ExtractPowerError) -> Self { - match &error { - ExtractPowerError::ExtractStringError { .. } => PyException::new_err(error.to_string()), - ExtractPowerError::NotPower { .. } => PyValueError::new_err(error.to_string()), - } - } -} - -// TODO: replace with a derive(PyFromStrLiteral) / #[literal = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration -impl<'a, 'py> FromPyObject<'a, 'py> for Power { - type Error = ExtractPowerError; - - fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { - let string: &str = obj.extract().context(ExtractStringSnafu)?; - - ensure!( - string == "power", - NotPowerSnafu { - actual: string.to_owned() - } - ); - - Ok(Self) - } -} - #[derive(Debug, FromPyObject)] #[pyo3(from_item_all)] pub struct PowerSensorAttributes { diff --git a/home-assistant/src/sensor/state_classes/measurement.rs b/home-assistant/src/sensor/state_classes/measurement.rs index 8998a8b..96064d7 100644 --- a/home-assistant/src/sensor/state_classes/measurement.rs +++ b/home-assistant/src/sensor/state_classes/measurement.rs @@ -1,48 +1,6 @@ -use pyo3::{ - exceptions::{PyException, PyValueError}, - prelude::*, -}; -use snafu::{ensure, ResultExt, Snafu}; +use python_utils::{FromPyFromStr, ToStrToPy}; +use string_literal::StringLiteral; -#[derive(Debug)] +#[derive(Debug, Clone, Copy, StringLiteral, FromPyFromStr, ToStrToPy)] +#[string_literal(value = "measurement")] pub struct Measurement; - -#[derive(Debug, Snafu)] -pub enum ExtractMeasurementError { - /// couldn't extract the object as a string - ExtractStringError { source: PyErr }, - - /// the string {actual:?} is not "measurement" like it's supposed to be - NotMeasurement { actual: String }, -} - -impl From for PyErr { - fn from(error: ExtractMeasurementError) -> Self { - match &error { - ExtractMeasurementError::ExtractStringError { .. } => { - PyException::new_err(error.to_string()) - } - ExtractMeasurementError::NotMeasurement { .. } => { - PyValueError::new_err(error.to_string()) - } - } - } -} - -// TODO: replace with a derive(PyFromStrLiteral) / #[literal = "state_changed"] once I learn how to make something like that and see about serde or strum integration or inspiration -impl<'a, 'py> FromPyObject<'a, 'py> for Measurement { - type Error = ExtractMeasurementError; - - fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { - let string: &str = obj.extract().context(ExtractStringSnafu)?; - - ensure!( - string == "measurement", - NotMeasurementSnafu { - actual: string.to_owned() - } - ); - - Ok(Self) - } -} diff --git a/string-literal-macros/Cargo.toml b/string-literal-macros/Cargo.toml new file mode 100644 index 0000000..125022c --- /dev/null +++ b/string-literal-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "string-literal-macros" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[lib] +proc-macro = true + +[dependencies] +darling = "0.23.0" +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } diff --git a/string-literal-macros/src/lib.rs b/string-literal-macros/src/lib.rs new file mode 100644 index 0000000..1bd0fe9 --- /dev/null +++ b/string-literal-macros/src/lib.rs @@ -0,0 +1,88 @@ +use darling::FromDeriveInput; +use proc_macro::TokenStream; +use proc_macro2::{Span as Span2, TokenStream as TokenStream2}; +use quote::{ToTokens, quote}; +use syn::{Ident, LitStr, Path, parse_quote, parse2}; + +#[derive(FromDeriveInput)] +#[darling(supports(struct_unit))] +#[darling(attributes(string_literal))] +struct StringLiteral { + ident: Ident, + value: String, +} + +impl ToTokens for StringLiteral { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let Self { ident, value } = self; + + dbg!("reached this point"); + + let as_ref_trait: Path = parse_quote!(::core::convert::AsRef); + let display_trait: Path = parse_quote!(::std::fmt::Display); + let from_trait: Path = parse_quote!(::core::convert::From); + let from_str_trait: Path = parse_quote!(::std::str::FromStr); + let result: Path = parse_quote!(::core::result::Result); + let wrong_literal_error_generic: Path = parse_quote!(::string_literal::WrongLiteralError); + let wrong_literal_error: Path = parse_quote!(#wrong_literal_error_generic<#ident>); + let write_macro: Path = parse_quote!(::std::write); + + let value_literal = LitStr::new(value.as_str(), Span2::call_site()); + + dbg!("reached this point"); + + let output = quote! { + impl #from_trait<#ident> for &'static str { + fn from(unit: #ident) -> Self { + #value_literal + } + } + + impl #from_str_trait for #ident { + type Err = #wrong_literal_error; + + fn from_str(s: &str) -> #result { + if s == #value_literal { + Ok(Self) + } else { + Err(#wrong_literal_error_generic { actual: s.to_owned(), expected: #ident }) + } + } + } + + impl #display_trait for #ident { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + #write_macro!(f, "state_changed") + } + } + + impl #as_ref_trait for #ident { + fn as_ref(&self) -> &str { + #value_literal + } + } + }; + dbg!(&output.to_string()); + + tokens.extend(output); + } +} + +fn string_literal_impl(input: TokenStream2) -> TokenStream2 { + let derive_input = match parse2(input) { + Ok(derive_input) => derive_input, + Err(error) => return error.into_compile_error(), + }; + + let string_literal = match StringLiteral::from_derive_input(&derive_input) { + Ok(string_literal) => string_literal, + Err(error) => return error.write_errors(), + }; + + quote! { #string_literal } +} + +#[proc_macro_derive(StringLiteral, attributes(string_literal))] +pub fn string_literal(input: TokenStream) -> TokenStream { + string_literal_impl(input.into()).into() +} diff --git a/string-literal/Cargo.toml b/string-literal/Cargo.toml new file mode 100644 index 0000000..afc4ab9 --- /dev/null +++ b/string-literal/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "string-literal" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[features] +macros = ["dep:string-literal-macros"] + +[dependencies] +string-literal-macros = { optional = true, path = "../string-literal-macros" } diff --git a/string-literal/src/lib.rs b/string-literal/src/lib.rs new file mode 100644 index 0000000..4968698 --- /dev/null +++ b/string-literal/src/lib.rs @@ -0,0 +1,24 @@ +use std::fmt::{Debug, Display}; + +#[cfg(feature = "macros")] +pub use string_literal_macros::StringLiteral; + +#[derive(Debug, Clone)] +pub struct WrongLiteralError { + pub actual: String, + pub expected: S, +} + +impl Display for WrongLiteralError +where + S: AsRef, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { actual, expected } = self; + let expected_str = expected.as_ref(); + + write!(f, "expected {expected_str:?} but got {actual:?}") + } +} + +impl std::error::Error for WrongLiteralError where WrongLiteralError: Debug + Display {}