Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contrib/graphql-codegen-client-preset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ yarn add -D @swc-contrib/plugin-graphql-codegen-client-preset

You will need to provide the `artifactDirectory` path that should be the same as the one configured in your `codegen.ts`

The plugin also supports a `namingConvention` option to match the naming convention configured in your `codegen.ts`. The default is `"change-case-all#pascalCase"` which matches the default for `@graphql-codegen/client-preset`. If you have set `namingConvention: "change-case-all#upperCaseFirst"` in your `codegen.ts`, you must also set it in the plugin options.

#### Vite

```ts
Expand Down
91 changes: 87 additions & 4 deletions contrib/graphql-codegen-client-preset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,76 @@ use swc_core::{
},
};

fn capetalize(s: &str) -> String {
format!("{}{}", &s[..1].to_uppercase(), &s[1..])
fn upper_case_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}

fn to_pascal_case(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut words: Vec<String> = Vec::new();
let mut word_start = 0;

for i in 1..chars.len() {
let is_boundary = if !chars[i - 1].is_alphanumeric() {
true
} else if chars[i - 1].is_lowercase() && chars[i].is_uppercase() {
// lowercase → uppercase (e.g., someEG: e→E)
true
} else if i + 1 < chars.len()
&& chars[i - 1].is_uppercase()
&& chars[i].is_uppercase()
&& chars[i + 1].is_lowercase()
{
// uppercase sequence followed by lowercase (e.g., EGRockets: G→R)
true
} else {
false
};

if is_boundary {
let word: String = chars[word_start..i]
.iter()
.filter(|c| c.is_alphanumeric())
.collect();
if !word.is_empty() {
words.push(word);
}
word_start = i;
}
}

let word: String = chars[word_start..]
.iter()
.filter(|c| c.is_alphanumeric())
.collect();
if !word.is_empty() {
words.push(word);
}

words
.iter()
.map(|w| {
let mut chars = w.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let rest = chars.as_str().to_lowercase();
format!("{}{}", first.to_uppercase(), rest)
}
}
})
.collect()
}

fn apply_naming_convention(s: &str, naming_convention: &str) -> String {
match naming_convention {
"change-case-all#upperCaseFirst" => upper_case_first(s),
_ => to_pascal_case(s),
Comment on lines +84 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle non-pascal namingConvention values instead of forcing PascalCase

apply_naming_convention only recognizes change-case-all#upperCaseFirst; every other string falls through to to_pascal_case. GraphQL Code Generator also accepts values like keep, change-case-all#lowerCase, lodash#camelCase, or a custom module path, so projects that mirror one of those valid configs into this plugin will still import PascalCased symbols. For example, with namingConvention: "keep", an operation like SomeEGRockets should stay SomeEGRocketsDocument, but this transform rewrites it to SomeEgRocketsDocument, which no longer matches the generated export.

Useful? React with 👍 / 👎.

}
}

#[cfg(test)]
Expand All @@ -28,6 +96,7 @@ pub struct GraphQLCodegenOptions {
pub cwd: String,
pub artifact_directory: String,
pub gql_tag_name: String,
pub naming_convention: String,
}

pub struct GraphQLVisitor {
Expand Down Expand Up @@ -161,11 +230,16 @@ impl VisitMut for GraphQLVisitor {
},
};

let import_name = apply_naming_convention(
&operation_name,
&self.options.naming_convention,
);

self.graphql_operations_or_fragments_to_import
.push(capetalize(&operation_name));
.push(import_name.clone());

// now change the call expression to a Identifier
let new_expr = Expr::Ident(quote_ident!(capetalize(&operation_name)).into());
let new_expr = Expr::Ident(quote_ident!(import_name).into());

*init = Box::new(new_expr);
}
Expand Down Expand Up @@ -231,13 +305,21 @@ impl VisitMut for GraphQLVisitor {
fn gql_default() -> String {
"gql".to_string()
}

fn naming_convention_default() -> String {
"change-case-all#pascalCase".to_string()
}

#[allow(non_snake_case)]
#[derive(Deserialize)]
struct PluginOptions {
artifactDirectory: String,

#[serde(default = "gql_default")]
gqlTagName: String,

#[serde(default = "naming_convention_default")]
namingConvention: String,
Comment on lines +321 to +322
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept object-form namingConvention configs

PluginOptions now deserializes namingConvention as a plain String, but valid Codegen configs also use the object form (for example to set enumValues separately or enable transformUnderscore). If a project follows the new README guidance and passes that same config into the SWC plugin, serde_json::from_str(...).expect(...) in process_transform will reject the object and abort the transform before compilation starts.

Useful? React with 👍 / 👎.

}

#[plugin_transform]
Expand Down Expand Up @@ -268,6 +350,7 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad
cwd,
artifact_directory,
gql_tag_name: plugin_config.gqlTagName,
naming_convention: plugin_config.namingConvention,
});

program.apply(&mut visit_mut_pass(visitor))
Expand Down
39 changes: 39 additions & 0 deletions contrib/graphql-codegen-client-preset/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ fn get_test_code_visitor() -> GraphQLVisitor {
cwd: "/home/faketestproject".to_string(),
artifact_directory: "./src/gql".to_string(),
gql_tag_name: "gql".to_string(),
naming_convention: "change-case-all#pascalCase".to_string(),
})
}

fn get_test_code_visitor_upper_case_first() -> GraphQLVisitor {
GraphQLVisitor::new(GraphQLCodegenOptions {
filename: "test.ts".to_string(),
cwd: "/home/faketestproject".to_string(),
artifact_directory: "./src/gql".to_string(),
gql_tag_name: "gql".to_string(),
naming_convention: "change-case-all#upperCaseFirst".to_string(),
})
}

Expand All @@ -40,6 +51,7 @@ fn import_files_from_same_directory(input_path: PathBuf) {
cwd: cwd.to_string_lossy().to_string(),
artifact_directory: "./tests/fixtures".to_string(),
gql_tag_name: "gql".to_string(),
naming_convention: "change-case-all#pascalCase".to_string(),
}))
},
&input_path,
Expand Down Expand Up @@ -71,6 +83,7 @@ fn import_files_from_other_directory(input_path: PathBuf) {
cwd: cwd.to_string_lossy().to_string(),
artifact_directory: cwd.to_string_lossy().to_string(),
gql_tag_name: "gql".to_string(),
naming_convention: "change-case-all#pascalCase".to_string(),
}))
},
&input_path,
Expand Down Expand Up @@ -144,3 +157,29 @@ const GetData = gql(`
}
`);"#
);

test!(
Default::default(),
|_| visit_mut_pass(get_test_code_visitor()),
pascal_case_converts_eg_word_boundaries,
r#"import gql from "gql-tag";

const SomeEGRockets = gql(`
query SomeEGRockets {
rockets
}
`);"#
);

test!(
Default::default(),
|_| visit_mut_pass(get_test_code_visitor_upper_case_first()),
upper_case_first_preserves_uppercase_sequences,
r#"import gql from "gql-tag";

const SomeEGRockets = gql(`
query SomeEGRockets {
rockets
}
`);"#
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SomeEgRocketsDocument } from "./src/gql/graphql";
import gql from "gql-tag";
const SomeEGRockets = SomeEgRocketsDocument;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SomeEGRocketsDocument } from "./src/gql/graphql";
import gql from "gql-tag";
const SomeEGRockets = SomeEGRocketsDocument;
Loading