use heck::ToSnekCase;
A Complete Example
A package from start to finish: Making a heckin’ case converter.
The Rust crate ecosystem is rich with very small and very powerful utility libraries. One of the most downloaded crates is heck. It provides traits and structs to perform some of the most common case conversions.
In this tutorial we’ll create a 0 dependency R package to provide the common case conversions. The resultant R package will be more performant but less flexible than the {snakecase}
R package.
This tutorial covers:
- vectorization
NA
handling- code generation using a macro
Getting started
Create a new R package:
::create_package("heck") usethis
When the new R package has opened up, add extendr
.
::use_extendr(crate_name = "rheck", lib_name = "rheck") rextendr
When adding the extendr dependency, make sure that the crate_name
and lib_name
arguments are not heck
. In order to add the heck
crate as a dependency, the crate itself cannot be called heck
because it creates a recursive dependency. Doing this allows us to name the R package {heck}
, but the internal Rust crate is called rheck
.
Next, heck
is needed as a dependency. From your terminal, navigate to src/rust
and run cargo add heck
. With this, you have everything you need to get started.
snek case conversion
Let’s start by creating a simple function to take a single string, and convert it to snake case. First, the trait ToSnekCase
needs to be imported so that the method to_snek_case()
is available to &str
.
use heck::ToSnekCase;
#[extendr]
fn to_snek_case(x: &str) -> String {
.to_snek_case()
x}
Simple enough, right? Let’s give it a shot. To make it accessible from your R session, it needs to be included in your extendr_module! {}
macro.
extendr_module! {
mod heck;
fn to_snek_case;
}
From your R session, run rextendr::document()
followed by devtools::load_all()
to make the function available. We’ll skip these step from now on, but be sure to remember it!
to_snek_case("MakeMe-Snake case")
#> [1] "make_me_snake_case"
Rarely is it useful to run a function on just a scalar character value. Rust, though, works with scalars by default and adding vectorization is another step.
to_snek_case(c("DontStep", "on-Snek"))
#> Error in to_snek_case(c("DontStep", "on-Snek")): Expected Scalar, got Strings
Providing a character vector causes an error. So how do you go about vectorizing?
vectorizing snek case conversion
To vectorize this function, you need to be apply the conversion to each element in a character vector. The extendr wrapper struct for a character vector is called Strings
. To take in a character vector and also return one, the function signature should look like this:
#[extendr]
fn to_snek_case(x: Strings) -> Strings {
}
This says there is an argument x
which must be a character vector and this function must also ->
return the Strings
(a character vector).
To iterate through this you can use the .into_iter()
method on the character vector.
#[extendr]
fn to_snek_case(x: Strings) -> Strings {
x.into_iter()
// the rest of the function
}
Iterators have a method called .map()
(yes, just like purrr::map()
). It lets you apply a closure (an anonymous function) to each element of the iterator. In this case, each element is an Rstr
. The Rstr
has a method .as_str()
which will return a string slice &str
. You can take this slice and pass it on to .to_snek_case()
. After having mapped over each element, the results are .collect()
ed into another Strings
.
#[extendr]
fn to_snek_case(x: Strings) -> Strings {
x.into_iter()
.map(|xi| {
.as_str().to_snek_case()
xi})
.collect::<Strings>()
}
This new version of the function can be used in a vectorized manner:
to_snek_case(c("DontStep", "on-Snek"))
#> [1] "dont_step" "on_snek"
But can it handle a missing value out of the box?
to_snek_case(c("DontStep", NA_character_, "on-Snek"))
#> [1] "dont_step" "na" "on_snek"
Well, sort of. The as_str()
method when used on a missing value will return "NA"
which is not in a user’s best interest.
handling missing values
Instead of returning "na"
, it would be better to return an actual missing value. Those can be created each scalar’s na()
method e.g. Rstr::na()
.
You can modify the .map()
statement to check if an NA
is present, and, if so, return an NA
value. To perform this check, use the is_na()
method which returns a bool
which is either true
or false
. The result can be match
ed. When it is missing, the match arm returns the NA
scalar value. When it is not missing, the Rstr
is converted to snek case. However, since the true
arm is an Rstr
the other false
arm must also be an Rstr
. To accomplish this use the Rstr::from()
method.
#[extendr]
fn to_snek_case(x: Strings) -> Strings {
.into_iter()
x.map(|xi| match xi.is_na() {
true => Rstr::na(),
false => Rstr::from(xi.as_str().to_snek_case()),
})
.collect::<Strings>()
}
This function can now handle missing values!
to_snek_case(c("DontStep", NA_character_, "on-Snek"))
#> [1] "dont_step" NA "on_snek"
automating other methods with a macro!
There are traits for the other case conversions such as ToKebabCase
, ToPascalCase
, ToShoutyKebabCase
and others. The each have a similar method name: .to_kebab_case()
, to_pascal_case()
, .to_shouty_kebab_case()
. You can either choose to copy the above and change the method call multiple times, or use a macro as a form of code generation.
A macro allows you to generate code in a short hand manner. This macro take an identifier which has a placeholder called $fn_name
: $fn_name:ident
.
macro_rules! make_heck_fn {
$fn_name:ident) => {
(#[extendr]
/// @export
fn $fn_name(x: Strings) -> Strings {
.into_iter()
x.map(|xi| match xi.is_na() {
true => Rstr::na(),
false => Rstr::from(xi.as_str().$fn_name()),
})
.collect::<Strings>()
}
};
}
The $fn_name
placeholder is put as the function name definition which is the same as the method name. To use this macro to generate the rest of the functions the other traits need to be imported.
use heck::{
, ToShoutyKebabCase,
ToKebabCase, ToShoutySnakeCase,
ToSnekCase, ToUpperCamelCase,
ToPascalCase, ToTitleCase,
ToTrainCase};
With the traits in scope, the macro can be invoked to generate the other functions.
make_heck_fn!(to_snek_case);
make_heck_fn!(to_shouty_snake_case);
make_heck_fn!(to_kebab_case);
make_heck_fn!(to_shouty_kebab_case);
make_heck_fn!(to_pascal_case);
make_heck_fn!(to_upper_camel_case);
make_heck_fn!(to_train_case);
make_heck_fn!(to_title_case);
Note that each of these functions should be added to the extendr_module! {}
macro in order for them to be available from R.
Test it out with the to_shouty_kebab_case()
function!
to_shouty_kebab_case("lorem:IpsumDolor__sit^amet")
#> [1] "LOREM-IPSUM-DOLOR-SIT-AMET"
And with that, you’ve created an R package that provides case conversion using heck and with very little code!
bench marking with {snakecase}
To illustrate the performance gains from using a vectorized Rust funciton, a bench::mark()
is created between to_snek_case()
and snakecase::to_snake_case()
.
The bench mark will use 5000 randomly generated lorem ipsum sentences.
<- unlist(lorem::ipsum(5000, 1, 25))
x
head(x)
#> [1] "Sit risus justo euismod luctus habitant vestibulum montes sodales nunc congue porttitor penatibus vivamus egestas malesuada malesuada eleifend dui malesuada maecenas ullamcorper mattis conubia hendrerit nibh."
#> [2] "Amet habitant diam sem ad viverra faucibus leo nascetur facilisis potenti taciti dapibus maecenas senectus primis nisl urna quis eu interdum quam praesent cubilia viverra aptent."
#> [3] "Elit luctus facilisis sociosqu nulla pretium accumsan senectus odio sociis per praesent augue augue mollis etiam nostra ut lacinia sollicitudin viverra blandit laoreet justo iaculis ac aenean in molestie."
#> [4] "Elit metus vehicula ante magna aptent ad tortor quis vestibulum a urna mus blandit nisi ligula lacus suspendisse sociosqu ullamcorper tristique duis erat sociosqu magnis vivamus mauris habitasse magnis."
#> [5] "Elit pellentesque at interdum neque ut ut himenaeos curabitur libero imperdiet pellentesque natoque et justo senectus fames dictumst vulputate risus iaculis nullam tristique libero convallis metus nisi urna non."
#> [6] "Sit odio fames posuere pellentesque auctor pellentesque aliquam scelerisque aptent egestas posuere sociis varius sapien enim dapibus lobortis egestas sem hendrerit aptent pellentesque consequat."
::mark(
benchrust = to_snek_case(x),
snakecase = snakecase::to_snake_case(x)
)#> # A tibble: 2 × 6
#> expression min median `itr/sec` mem_alloc `gc/sec`
#> <bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl>
#> 1 rust 16.6ms 18ms 55.6 1.17MB 0
#> 2 snakecase 267.8ms 270ms 3.70 12.29MB 5.55
The memory usage for rust-based functions is likely quite understated. This is because memory allocated outside of R cannot be tracked.
See bench::mark documentation for more.
The whole thing
In just 42 lines of code (empty lines included), you can create a very performant R package!
use extendr_api::prelude::*;
use heck::{
, ToPascalCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnekCase, ToTitleCase,
ToKebabCase, ToUpperCamelCase,
ToTrainCase};
macro_rules! make_heck_fn {
$fn_name:ident) => {
(#[extendr]
/// @export
fn $fn_name(x: Strings) -> Strings {
.into_iter()
x.map(|xi| match xi.is_na() {
true => Rstr::na(),
false => Rstr::from(xi.as_str().$fn_name()),
})
.collect::<Strings>()
}
};
}
make_heck_fn!(to_snek_case);
make_heck_fn!(to_shouty_snake_case);
make_heck_fn!(to_kebab_case);
make_heck_fn!(to_shouty_kebab_case);
make_heck_fn!(to_pascal_case);
make_heck_fn!(to_upper_camel_case);
make_heck_fn!(to_train_case);
make_heck_fn!(to_title_case);
extendr_module! {
mod heck;
fn to_snek_case;
fn to_shouty_snake_case;
fn to_kebab_case;
fn to_shouty_kebab_case;
fn to_pascal_case;
fn to_upper_camel_case;
fn to_title_case;
fn to_train_case; }