Skip to content

Commit

Permalink
Merge pull request #1084 from neon-bindings/kv/camel-case
Browse files Browse the repository at this point in the history
feat(neon-macros): Convert snake_case to camelCase when exporting functions
  • Loading branch information
kjvalencik authored Nov 26, 2024
2 parents 7780a2f + 00c1598 commit cb8840a
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 31 deletions.
85 changes: 84 additions & 1 deletion crates/neon-macros/src/export/function/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ pub(super) fn export(meta: meta::Meta, input: syn::ItemFn) -> proc_macro::TokenS
let export_name = meta
.name
.map(|name| quote::quote!(#name))
.unwrap_or_else(|| quote::quote!(stringify!(#name)));
.unwrap_or_else(|| {
let name = to_camel_case(&name.to_string());
quote::quote!(#name)
});

// Generate the function that is registered to create the function on addon initialization.
// Braces are included to prevent names from polluting user code.
Expand Down Expand Up @@ -386,3 +389,83 @@ fn check_this(opts: &meta::Meta, sig: &syn::Signature, has_context: bool) -> boo
_ => false,
}
}

// Convert identifiers to camel case with the following rules:
// * All leading and trailing underscores are preserved
// * All other underscores are removed
// * Characters immediately following a non-leading underscore are uppercased
// * Bail (no conversion) if an unexpected condition is encountered:
// - Uppercase character
// - More than one adjacent interior underscore
fn to_camel_case(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut it = name.chars();
let mut next = it.next();
let mut count = 0usize;

// Keep leading underscores
while matches!(next, Some('_')) {
out.push('_');
next = it.next();
}

// Convert to camel case
while let Some(c) = next {
match c {
// Keep a count for maintaining trailing underscores
'_' => count += 1,

// Bail if there is an unexpected uppercase character or extra underscore
_ if c.is_uppercase() || count >= 2 => {
return name.to_string();
}

// Don't uppercase the middle of a word
_ if count == 0 => {
out.push(c);
count = 0;
}

// Uppercase characters following an underscore
_ => {
out.extend(c.to_uppercase());
count = 0;
}
}

next = it.next();
}

// We don't know underscores are a suffix until iteration has completed;
// add them back.
for _ in 0..count {
out.push('_');
}

out
}

#[cfg(test)]
mod test {
#[test]
fn to_camel_case() {
use super::to_camel_case;

assert_eq!(to_camel_case(""), "");
assert_eq!(to_camel_case("one"), "one");
assert_eq!(to_camel_case("two_words"), "twoWords");
assert_eq!(to_camel_case("three_word_name"), "threeWordName");
assert_eq!(to_camel_case("extra__underscore"), "extra__underscore");
assert_eq!(to_camel_case("PreserveCase"), "PreserveCase");
assert_eq!(to_camel_case("PreServe_case"), "PreServe_case");
assert_eq!(to_camel_case("_preserve_leading"), "_preserveLeading");
assert_eq!(to_camel_case("__preserve_leading"), "__preserveLeading");
assert_eq!(to_camel_case("preserve_trailing_"), "preserveTrailing_");
assert_eq!(to_camel_case("preserve_trailing__"), "preserveTrailing__");
assert_eq!(to_camel_case("_preserve_both_"), "_preserveBoth_");
assert_eq!(to_camel_case("__preserve_both__"), "__preserveBoth__");
assert_eq!(to_camel_case("_"), "_");
assert_eq!(to_camel_case("__"), "__");
assert_eq!(to_camel_case("___"), "___");
}
}
36 changes: 36 additions & 0 deletions crates/neon/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,42 @@ pub use neon_macros::main;
/// }
/// ```
///
/// ### Naming exported functions
///
/// Conventionally, Rust uses `snake_case` for function identifiers and JavaScript uses `camelCase`.
/// By default, Neon will attempt to convert function names to camel case. For example:
///
/// ```rust
/// #[neon::export]
/// fn add_one(n: f64) -> f64 {
/// n + 1.0
/// }
/// ```
///
/// The `add_one` function will be exported as `addOne` in JavaScript.
///
/// ```js
/// import { addOne } from ".";
/// ```
///
/// [Similar to globals](#renaming-an-export), exported functions can be overridden with the `name`
/// attribute.
///
/// ```rust
/// #[neon::export(name = "addOneSync")]
/// fn add_one(n: f64) -> f64 {
/// n + 1.0
/// }
/// ```
/// Neon uses the following rules when converting `snake_case` to `camelCase`:
///
/// * All _leading_ and _trailing_ underscores (`_`) are preserved
/// * Characters _immediately_ following a _non-leading_ underscore are converted to uppercase
/// * If the identifier contains an _unexpected_ character, **no** conversion is performed and
/// the identifier is used _unchanged_. Unexpected characters include:
/// - Uppercase characters
/// - Duplicate _interior_ (non-leading, non-trailing underscores)
///
/// ### Exporting a function that uses JSON
///
/// The [`Json`](crate::types::extract::Json) wrapper allows ergonomically handling complex
Expand Down
20 changes: 10 additions & 10 deletions test/napi/lib/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ function globals() {

function functions() {
it("void function", () => {
assert.strictEqual(addon.no_args_or_return(), undefined);
assert.strictEqual(addon.noArgsOrReturn(), undefined);
});

it("add - sync", () => {
assert.strictEqual(addon.simple_add(1, 2), 3);
assert.strictEqual(addon.simpleAdd(1, 2), 3);
assert.strictEqual(addon.renamedAdd(1, 2), 3);
});

it("add - task", async () => {
const p1 = addon.add_task(1, 2);
const p1 = addon.addTask(1, 2);
const p2 = addon.renamedAddTask(1, 2);

assert.ok(p1 instanceof Promise);
Expand All @@ -45,14 +45,14 @@ function functions() {
const arr = ["b", "c", "a"];
const expected = [...arr].sort();

assert.deepStrictEqual(addon.json_sort(arr), expected);
assert.deepStrictEqual(addon.jsonSort(arr), expected);
assert.deepStrictEqual(addon.renamedJsonSort(arr), expected);
});

it("json sort - task", async () => {
const arr = ["b", "c", "a"];
const expected = [...arr].sort();
const p1 = addon.json_sort_task(arr);
const p1 = addon.jsonSortTask(arr);
const p2 = addon.renamedJsonSortTask(arr);

assert.ok(p1 instanceof Promise);
Expand All @@ -63,7 +63,7 @@ function functions() {
});

it("can use context and handles", () => {
const actual = addon.concat_with_cx_and_handle("Hello,", " World!");
const actual = addon.concatWithCxAndHandle("Hello,", " World!");
const expected = "Hello, World!";

assert.strictEqual(actual, expected);
Expand All @@ -73,20 +73,20 @@ function functions() {
const msg = "Oh, no!";
const expected = new Error(msg);

assert.throws(() => addon.fail_with_throw(msg), expected);
assert.throws(() => addon.failWithThrow(msg), expected);
});

it("tasks are concurrent", async () => {
const time = 500;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const start = process.hrtime.bigint();

await Promise.all([addon.sleep_task(time), sleep(time)]);
await Promise.all([addon.sleepTask(time), sleep(time)]);

const end = process.hrtime.bigint();
const duration = end - start;

// If `addon.sleep_task` blocks the thread, the tasks will run sequentially
// If `addon.sleepTask` blocks the thread, the tasks will run sequentially
// and take a minimum of 2x `time`. Since they are run concurrently, we
// expect the time to be closer to 1x `time`.
const maxExpected = 2000000n * BigInt(time);
Expand All @@ -95,6 +95,6 @@ function functions() {
});

it("can use generic Cx in exported functions", () => {
assert.strictEqual(addon.number_with_cx(42), 42);
assert.strictEqual(addon.numberWithCx(42), 42);
});
}
6 changes: 3 additions & 3 deletions test/napi/lib/extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ describe("Extractors", () => {
});

it("Either", () => {
assert.strictEqual(addon.extract_either("hello"), "String: hello");
assert.strictEqual(addon.extract_either(42), "Number: 42");
assert.strictEqual(addon.extractEither("hello"), "String: hello");
assert.strictEqual(addon.extractEither(42), "Number: 42");

assert.throws(
() => addon.extract_either({}),
() => addon.extractEither({}),
(err) => {
assert.match(err.message, /expected either.*String.*f64/);
assert.match(err.left.message, /expected string/);
Expand Down
20 changes: 7 additions & 13 deletions test/napi/lib/futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,23 @@ describe("Futures", () => {

describe("Exported Async Functions", () => {
it("should be able to call `async fn`", async () => {
assert.strictEqual(await addon.async_fn_add(1, 2), 3);
assert.strictEqual(await addon.asyncFnAdd(1, 2), 3);
});

it("should be able to call fn with async block", async () => {
assert.strictEqual(await addon.async_add(1, 2), 3);
assert.strictEqual(await addon.asyncAdd(1, 2), 3);
});

it("should be able to call fallible `async fn`", async () => {
assert.strictEqual(await addon.async_fn_div(10, 2), 5);
assert.strictEqual(await addon.asyncFnDiv(10, 2), 5);

await assertRejects(() => addon.async_fn_div(10, 0), /Divide by zero/);
});

it("should be able to call fallible `async fn`", async () => {
assert.strictEqual(await addon.async_fn_div(10, 2), 5);

await assertRejects(() => addon.async_fn_div(10, 0), /Divide by zero/);
await assertRejects(() => addon.asyncFnDiv(10, 0), /Divide by zero/);
});

it("should be able to call fallible fn with async block", async () => {
assert.strictEqual(await addon.async_div(10, 2), 5);
assert.strictEqual(await addon.asyncDiv(10, 2), 5);

await assertRejects(() => addon.async_div(10, 0), /Divide by zero/);
await assertRejects(() => addon.asyncDiv(10, 0), /Divide by zero/);
});

it("should be able to code on the event loop before and after async", async () => {
Expand All @@ -100,7 +94,7 @@ describe("Futures", () => {
process.on("async_with_events", eventHandler);

try {
let res = await addon.async_with_events([
let res = await addon.asyncWithEvents([
[1, 2],
[3, 4],
[5, 6],
Expand Down
8 changes: 4 additions & 4 deletions test/napi/src/js/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fn simple_add(a: f64, b: f64) -> f64 {
}

#[neon::export(name = "renamedAdd")]
fn renamed_add(a: f64, b: f64) -> f64 {
fn rs_renamed_add(a: f64, b: f64) -> f64 {
simple_add(a, b)
}

Expand All @@ -37,7 +37,7 @@ fn add_task(a: f64, b: f64) -> f64 {
}

#[neon::export(task, name = "renamedAddTask")]
fn renamed_add_task(a: f64, b: f64) -> f64 {
fn rs_renamed_add_task(a: f64, b: f64) -> f64 {
add_task(a, b)
}

Expand All @@ -48,7 +48,7 @@ fn json_sort(mut items: Vec<String>) -> Vec<String> {
}

#[neon::export(json, name = "renamedJsonSort")]
fn renamed_json_sort(items: Vec<String>) -> Vec<String> {
fn rs_renamed_json_sort(items: Vec<String>) -> Vec<String> {
json_sort(items)
}

Expand All @@ -58,7 +58,7 @@ fn json_sort_task(items: Vec<String>) -> Vec<String> {
}

#[neon::export(json, name = "renamedJsonSortTask", task)]
fn renamed_json_sort_task(items: Vec<String>) -> Vec<String> {
fn rs_renamed_json_sort_task(items: Vec<String>) -> Vec<String> {
json_sort(items)
}

Expand Down

0 comments on commit cb8840a

Please sign in to comment.