graphql_lint/
lib.rs

1use cynic_parser::type_system::{
2    Definition, Directive, DirectiveDefinition, EnumDefinition, EnumValueDefinition, FieldDefinition,
3    InputObjectDefinition, InputValueDefinition, InterfaceDefinition, ObjectDefinition, ScalarDefinition,
4    TypeDefinition, UnionDefinition,
5};
6use cynic_parser::TypeSystemDocument;
7use heck::{ToLowerCamelCase, ToPascalCase, ToShoutySnakeCase};
8use thiserror::Error;
9
10enum CaseMatch<'a> {
11    Correct,
12    Incorrect { current: &'a str, fix: String },
13}
14
15enum Case {
16    Pascal,
17    ShoutySnake,
18    Camel,
19}
20
21pub enum Severity {
22    Warning,
23}
24
25#[derive(Error, Debug)]
26pub enum LinterError {
27    #[error("encountered a parsing error:\n{0}")]
28    Parse(String),
29}
30
31pub fn lint(schema: &str) -> Result<Vec<(String, Severity)>, LinterError> {
32    let parsed_schema =
33        cynic_parser::parse_type_system_document(schema).map_err(|error| LinterError::Parse(error.to_string()))?;
34    Ok(SchemaLinter::new().lint(&parsed_schema))
35}
36
37struct SchemaLinter {
38    diagnostics: Vec<(String, Severity)>,
39}
40
41impl<'a> SchemaLinter {
42    pub fn new() -> Self {
43        Self {
44            diagnostics: Vec::new(),
45        }
46    }
47
48    pub fn lint(mut self, schema: &'a TypeSystemDocument) -> Vec<(String, Severity)> {
49        schema.definitions().for_each(|definition| match definition {
50            Definition::Schema(_) => {}
51            Definition::SchemaExtension(_) => {}
52            // TODO: we can optimize this by not rechecking spelling for extensions.
53            // We'll also need to do this to avoid duplicate warnings if extending a type with an incorrect name
54            Definition::TypeExtension(r#type) | Definition::Type(r#type) => {
55                match r#type {
56                    TypeDefinition::Scalar(scalar) => {
57                        self.visit_scalar(scalar);
58                        scalar
59                            .directives()
60                            .for_each(|directive| self.visit_directive_usage(r#type, directive));
61                    }
62                    TypeDefinition::Object(object) => {
63                        self.visit_object(object);
64                        object
65                            .directives()
66                            .for_each(|directive| self.visit_directive_usage(r#type, directive));
67                        object.fields().for_each(|field| {
68                            self.visit_field(r#type, field);
69                            field
70                                .arguments()
71                                .for_each(|argument| self.visit_field_argument(r#type, field, argument));
72                            field.directives().for_each(|directive_usage| {
73                                self.visit_directive_usage_field(r#type, field, directive_usage);
74                            });
75                        });
76                    }
77                    TypeDefinition::Interface(interface) => {
78                        self.visit_interface(interface);
79                        interface
80                            .directives()
81                            .for_each(|directive| self.visit_directive_usage(r#type, directive));
82                        interface.fields().for_each(|field| {
83                            self.visit_field(r#type, field);
84                            field
85                                .arguments()
86                                .for_each(|argument| self.visit_field_argument(r#type, field, argument));
87                            field.directives().for_each(|directive_usage| {
88                                self.visit_directive_usage_field(r#type, field, directive_usage);
89                            });
90                        });
91                    }
92                    TypeDefinition::Union(union) => {
93                        self.visit_union(union);
94                        union
95                            .directives()
96                            .for_each(|directive| self.visit_directive_usage(r#type, directive))
97                    }
98                    TypeDefinition::Enum(r#enum) => {
99                        self.visit_enum(r#enum);
100                        r#enum
101                            .directives()
102                            .for_each(|directive| self.visit_directive_usage(r#type, directive));
103                        r#enum.values().for_each(|value| {
104                            self.visit_enum_value(r#type, value);
105                            value.directives().for_each(|directive_usage| {
106                                self.visit_directive_usage_enum_value(r#enum, value, directive_usage);
107                            });
108                        });
109                    }
110                    TypeDefinition::InputObject(input_object) => {
111                        self.visit_input_object(input_object);
112                        input_object
113                            .directives()
114                            .for_each(|directive| self.visit_directive_usage(r#type, directive));
115                        input_object.fields().for_each(|input_value| {
116                            self.visit_input_value(r#type, input_value);
117                            input_value.directives().for_each(|directive_usage| {
118                                self.visit_directive_usage_input_value(input_object, input_value, directive_usage);
119                            });
120                        });
121                    }
122                };
123            }
124            Definition::Directive(directive) => {
125                self.visit_directive(directive);
126                directive
127                    .arguments()
128                    .for_each(|argument| self.visit_directive_argument(directive, argument));
129            }
130        });
131
132        self.diagnostics
133    }
134
135    fn case_check(current: &'a str, case: Case) -> CaseMatch<'_> {
136        let fix = match case {
137            Case::Pascal => current.to_pascal_case(),
138            Case::ShoutySnake => current.to_shouty_snake_case(),
139            Case::Camel => current.to_lower_camel_case(),
140        };
141
142        if fix == current {
143            CaseMatch::Correct
144        } else {
145            CaseMatch::Incorrect { current, fix }
146        }
147    }
148
149    pub fn visit_field_argument(
150        &mut self,
151        parent_type: TypeDefinition<'_>,
152        field: FieldDefinition<'_>,
153        argument: InputValueDefinition<'_>,
154    ) {
155        if let CaseMatch::Incorrect { current, fix } = Self::case_check(argument.name(), Case::Camel) {
156            self.diagnostics.push((
157                format!(
158                    "argument '{current}' on field '{}' on {} '{}' should be renamed to '{fix}'",
159                    field.name(),
160                    Self::type_definition_display(parent_type),
161                    parent_type.name()
162                ),
163                Severity::Warning,
164            ));
165        }
166    }
167
168    pub fn visit_directive_argument(&mut self, directive: DirectiveDefinition<'_>, argument: InputValueDefinition<'_>) {
169        if let CaseMatch::Incorrect { current, fix } = Self::case_check(argument.name(), Case::Camel) {
170            self.diagnostics.push((
171                format!(
172                    "argument '{current}' on directive '{}' should be renamed to '{fix}'",
173                    directive.name()
174                ),
175                Severity::Warning,
176            ));
177        }
178    }
179
180    pub fn visit_input_value(&mut self, parent: TypeDefinition<'_>, value: InputValueDefinition<'_>) {
181        if let CaseMatch::Incorrect { current, fix } = Self::case_check(value.name(), Case::Camel) {
182            self.diagnostics.push((
183                format!(
184                    "input value '{current}' on input '{}' should be renamed to '{fix}'",
185                    parent.name()
186                ),
187                Severity::Warning,
188            ));
189        }
190    }
191
192    fn type_definition_display(kind: TypeDefinition<'_>) -> &'static str {
193        match kind {
194            TypeDefinition::Scalar(_) => "scalar",
195            TypeDefinition::Object(_) => "type",
196            TypeDefinition::Interface(_) => "interface",
197            TypeDefinition::Union(_) => "union",
198            TypeDefinition::Enum(_) => "enum",
199            TypeDefinition::InputObject(_) => "input",
200        }
201    }
202
203    pub fn visit_field(&mut self, parent: TypeDefinition<'_>, field: FieldDefinition<'_>) {
204        let field_name = field.name();
205
206        // ignore system fields
207        if field_name.starts_with("__") {
208            return;
209        }
210
211        if let CaseMatch::Incorrect { current, fix } = Self::case_check(field_name, Case::Camel) {
212            self.diagnostics.push((
213                format!(
214                    "field '{current}' on {} '{}' should be renamed to '{fix}'",
215                    Self::type_definition_display(parent),
216                    parent.name()
217                ),
218                Severity::Warning,
219            ));
220        }
221        match parent.name() {
222            "Query" => {
223                for prefix in ["query", "get", "list"] {
224                    if field_name.starts_with(prefix) {
225                        self.diagnostics.push((
226                            format!("field '{field_name}' on type 'Query' has a forbidden prefix: '{prefix}'"),
227                            Severity::Warning,
228                        ));
229                        break;
230                    }
231                }
232                if field_name.ends_with("Query") {
233                    self.diagnostics.push((
234                        format!("field '{field_name}' on type 'Query' has a forbidden suffix: 'Query'"),
235                        Severity::Warning,
236                    ));
237                }
238            }
239            "Mutation" => {
240                for prefix in ["mutation", "put", "post", "patch"] {
241                    if field_name.starts_with(prefix) {
242                        self.diagnostics.push((
243                            format!("field '{field_name}' on type 'Mutation' has a forbidden prefix: '{prefix}'"),
244                            Severity::Warning,
245                        ));
246                        break;
247                    }
248                }
249                if field_name.ends_with("Mutation") {
250                    self.diagnostics.push((
251                        format!("field '{field_name}' on type 'Mutation' has a forbidden suffix: 'Mutation'"),
252                        Severity::Warning,
253                    ));
254                }
255            }
256            "Subscription" => {
257                if field_name.starts_with("subscription") {
258                    self.diagnostics.push((
259                        format!("field '{field_name}' on type 'Subscription' has a forbidden prefix: 'subscription'"),
260                        Severity::Warning,
261                    ));
262                }
263                if field_name.ends_with("Subscription") {
264                    self.diagnostics.push((
265                        format!("field '{field_name}' on type 'Subscription' has a forbidden suffix: 'Subscription'"),
266                        Severity::Warning,
267                    ));
268                }
269            }
270            _ => {}
271        }
272    }
273
274    pub fn visit_directive(&mut self, directive: DirectiveDefinition<'_>) {
275        if let CaseMatch::Incorrect { current, fix } = Self::case_check(directive.name(), Case::Camel) {
276            self.diagnostics.push((
277                format!("directive '{current}' should be renamed to '{fix}'"),
278                Severity::Warning,
279            ));
280        }
281    }
282
283    pub fn visit_directive_usage(&mut self, parent: TypeDefinition<'_>, directive: Directive<'_>) {
284        if directive.name() == "deprecated" && !directive.arguments().any(|argument| argument.name() == "reason") {
285            self.diagnostics.push((
286                format!(
287                    "usage of directive 'deprecated' on {} '{}' does not populate the 'reason' argument",
288                    Self::type_definition_display(parent),
289                    parent.name()
290                ),
291                Severity::Warning,
292            ));
293        }
294    }
295
296    pub fn visit_directive_usage_field(
297        &mut self,
298        parent_type: TypeDefinition<'_>,
299        parent_field: FieldDefinition<'_>,
300        directive: Directive<'_>,
301    ) {
302        if directive.name() == "deprecated" && !directive.arguments().any(|argument| argument.name() == "reason") {
303            self.diagnostics.push((
304                format!(
305                    "usage of directive 'deprecated' on field '{}' on {} '{}' does not populate the 'reason' argument",
306                    parent_field.name(),
307                    Self::type_definition_display(parent_type),
308                    parent_type.name()
309                ),
310                Severity::Warning,
311            ));
312        }
313    }
314
315    pub fn visit_directive_usage_input_value(
316        &mut self,
317        parent_input: InputObjectDefinition<'_>,
318        parent_input_value: InputValueDefinition<'_>,
319        directive: Directive<'_>,
320    ) {
321        if directive.name() == "deprecated" && !directive.arguments().any(|argument| argument.name() == "reason") {
322            self.diagnostics.push((
323                format!(
324                    "usage of directive 'deprecated' on input value '{}' on input '{}' does not populate the 'reason' argument",
325                    parent_input_value.name(),
326                    parent_input.name()
327                ),
328                Severity::Warning,
329            ));
330        }
331    }
332
333    pub fn visit_directive_usage_enum_value(
334        &mut self,
335        parent_enum: EnumDefinition<'_>,
336        parent_value: EnumValueDefinition<'_>,
337        directive: Directive<'_>,
338    ) {
339        if directive.name() == "deprecated" && !directive.arguments().any(|argument| argument.name() == "reason") {
340            self.diagnostics.push((
341                format!(
342                    "usage of directive 'deprecated' on enum value '{}' on enum '{}' does not populate the 'reason' argument",
343                    parent_value.value(),
344                    parent_enum.name()
345                ),
346                Severity::Warning,
347            ));
348        }
349    }
350
351    pub fn visit_input_object(&mut self, _input_object: InputObjectDefinition<'_>) {}
352
353    pub fn visit_union(&mut self, union: UnionDefinition<'_>) {
354        let union_name = union.name();
355        if union_name.starts_with("Union") {
356            self.diagnostics.push((
357                format!("union '{union_name}' has a forbidden prefix: 'Union'"),
358                Severity::Warning,
359            ));
360        }
361        if union_name.ends_with("Union") {
362            self.diagnostics.push((
363                format!("union '{union_name}' has a forbidden suffix: 'Union'"),
364                Severity::Warning,
365            ));
366        }
367    }
368
369    pub fn visit_scalar(&mut self, _scalar: ScalarDefinition<'_>) {}
370
371    pub fn visit_interface(&mut self, object: InterfaceDefinition<'_>) {
372        let interface_name = object.name();
373        if interface_name.starts_with("Interface") {
374            self.diagnostics.push((
375                format!("interface '{interface_name}' has a forbidden prefix: 'Interface'"),
376                Severity::Warning,
377            ));
378        }
379        if interface_name.ends_with("Interface") {
380            self.diagnostics.push((
381                format!("interface '{interface_name}' has a forbidden suffix: 'Interface'"),
382                Severity::Warning,
383            ));
384        }
385    }
386
387    pub fn visit_object(&mut self, object: ObjectDefinition<'_>) {
388        let object_name = object.name();
389
390        if let CaseMatch::Incorrect { current, fix } = Self::case_check(object_name, Case::Pascal) {
391            self.diagnostics.push((
392                format!("type '{current}' should be renamed to '{fix}'"),
393                Severity::Warning,
394            ));
395        }
396        if object_name.starts_with("Type") {
397            self.diagnostics.push((
398                format!("type '{object_name}' has a forbidden prefix: 'Type'"),
399                Severity::Warning,
400            ));
401        }
402        if object_name.ends_with("Type") {
403            self.diagnostics.push((
404                format!("type '{object_name}' has a forbidden suffix: 'Type'"),
405                Severity::Warning,
406            ));
407        }
408    }
409
410    pub fn visit_enum(&mut self, r#enum: EnumDefinition<'_>) {
411        let enum_name = r#enum.name();
412        if let CaseMatch::Incorrect { current, fix } = Self::case_check(enum_name, Case::Pascal) {
413            self.diagnostics.push((
414                format!("enum '{current}' should be renamed to '{fix}'"),
415                Severity::Warning,
416            ));
417        }
418        if enum_name.starts_with("Enum") {
419            self.diagnostics.push((
420                format!("enum '{enum_name}' has a forbidden prefix: 'Enum'"),
421                Severity::Warning,
422            ));
423        }
424        if enum_name.ends_with("Enum") {
425            self.diagnostics.push((
426                format!("enum '{enum_name}' has a forbidden suffix: 'Enum'"),
427                Severity::Warning,
428            ));
429        }
430    }
431
432    pub fn visit_enum_value(&mut self, parent: TypeDefinition<'_>, enum_value: EnumValueDefinition<'_>) {
433        let enum_name = parent.name();
434
435        let name = enum_value.value();
436        if let CaseMatch::Incorrect { current, fix } = Self::case_check(name, Case::ShoutySnake) {
437            self.diagnostics.push((
438                format!("value '{current}' on enum '{enum_name}' should be renamed to '{fix}'"),
439                Severity::Warning,
440            ));
441        }
442    }
443}
444
445#[test]
446fn linter() {
447    use criterion as _;
448
449    let schema = r#"
450        directive @WithDeprecatedArgs(
451          ARG: String @deprecated(reason: "Use `newArg`")
452          newArg: String
453        ) on FIELD
454
455        enum Enum_lowercase @deprecated {
456          an_enum_member @deprecated
457        }
458
459        enum lowercase_Enum {
460          an_enum_member @deprecated
461        }
462        
463        type Query {
464          __test: String,
465          getHello(name: String!): Enum_lowercase!
466          queryHello(name: String!): Enum_lowercase!
467          listHello(name: String!): Enum_lowercase!
468          helloQuery(name: String!): Enum_lowercase!
469        }
470
471        type Mutation {
472          __test: String,
473          putHello(name: String!): Enum_lowercase!
474          mutationHello(name: String!): Enum_lowercase!
475          postHello(name: String!): Enum_lowercase!
476          patchHello(name: String!): Enum_lowercase!
477          helloMutation(name: String!): Enum_lowercase!
478        }
479
480        type Subscription {
481          __test: String,
482          subscriptionHello(name: String!): Enum_lowercase!
483          helloSubscription(name: String!): Enum_lowercase!
484        }
485
486        type TypeTest {
487          name: String @deprecated
488        }
489
490        type TestType {
491           name: string
492        }
493
494        type other {
495           name: string
496        }
497
498        scalar CustomScalar @specifiedBy(url: "https://specs.example.com/rfc1") @deprecated
499
500        union UnionTest @deprecated = testType | typeTest
501
502        union TestUnion = testType | typeTest
503
504        interface GameInterface {
505          title: String!
506          publisher: String! @deprecated
507        }
508
509        interface InterfaceGame @deprecated {
510          title: String!
511          publisher: String!
512        }
513
514        input TEST @deprecated {
515          OTHER: String @deprecated
516        }
517
518        type hello @deprecated {
519          Test(NAME: String): String
520        }
521
522        extend type hello {
523          GOODBYE: String
524        }
525
526        schema {
527          query: Query
528          mutation: Mutation
529        }
530    "#;
531
532    let diagnostics = lint(schema).unwrap();
533
534    assert!(!diagnostics.is_empty());
535
536    let messages = diagnostics
537        .iter()
538        .map(|diagnostic| diagnostic.0.clone())
539        .collect::<Vec<_>>();
540    dbg!(&messages);
541
542    [
543        "directive 'WithDeprecatedArgs' should be renamed to 'withDeprecatedArgs'",
544        "argument 'ARG' on directive 'WithDeprecatedArgs' should be renamed to 'arg'",
545        "enum 'Enum_lowercase' should be renamed to 'EnumLowercase'",
546        "enum 'Enum_lowercase' has a forbidden prefix: 'Enum'",
547        "usage of directive 'deprecated' on enum 'Enum_lowercase' does not populate the 'reason' argument",
548        "value 'an_enum_member' on enum 'Enum_lowercase' should be renamed to 'AN_ENUM_MEMBER'",
549        "usage of directive 'deprecated' on enum value 'an_enum_member' on enum 'Enum_lowercase' does not populate the 'reason' argument",
550        "enum 'lowercase_Enum' should be renamed to 'LowercaseEnum'",
551        "enum 'lowercase_Enum' has a forbidden suffix: 'Enum'",
552        "value 'an_enum_member' on enum 'lowercase_Enum' should be renamed to 'AN_ENUM_MEMBER'",
553        "usage of directive 'deprecated' on enum value 'an_enum_member' on enum 'lowercase_Enum' does not populate the 'reason' argument",
554        "field 'getHello' on type 'Query' has a forbidden prefix: 'get'",
555        "field 'queryHello' on type 'Query' has a forbidden prefix: 'query'",
556        "field 'listHello' on type 'Query' has a forbidden prefix: 'list'",
557        "field 'helloQuery' on type 'Query' has a forbidden suffix: 'Query'",
558        "field 'putHello' on type 'Mutation' has a forbidden prefix: 'put'",
559        "field 'mutationHello' on type 'Mutation' has a forbidden prefix: 'mutation'",
560        "field 'postHello' on type 'Mutation' has a forbidden prefix: 'post'",
561        "field 'patchHello' on type 'Mutation' has a forbidden prefix: 'patch'",
562        "field 'helloMutation' on type 'Mutation' has a forbidden suffix: 'Mutation'",
563        "field 'subscriptionHello' on type 'Subscription' has a forbidden prefix: 'subscription'",
564        "field 'helloSubscription' on type 'Subscription' has a forbidden suffix: 'Subscription'",
565        "type 'TypeTest' has a forbidden prefix: 'Type'",
566        "usage of directive 'deprecated' on field 'name' on type 'TypeTest' does not populate the 'reason' argument",
567        "type 'TestType' has a forbidden suffix: 'Type'",
568        "type 'other' should be renamed to 'Other'",
569        "usage of directive 'deprecated' on scalar 'CustomScalar' does not populate the 'reason' argument",
570        "union 'UnionTest' has a forbidden prefix: 'Union'",
571        "usage of directive 'deprecated' on union 'UnionTest' does not populate the 'reason' argument",
572        "union 'TestUnion' has a forbidden suffix: 'Union'",
573        "interface 'GameInterface' has a forbidden suffix: 'Interface'",
574        "usage of directive 'deprecated' on field 'publisher' on interface 'GameInterface' does not populate the 'reason' argument",
575        "interface 'InterfaceGame' has a forbidden prefix: 'Interface'",
576        "usage of directive 'deprecated' on interface 'InterfaceGame' does not populate the 'reason' argument",
577        "usage of directive 'deprecated' on input 'TEST' does not populate the 'reason' argument",
578        "input value 'OTHER' on input 'TEST' should be renamed to 'other'",
579        "usage of directive 'deprecated' on input value 'OTHER' on input 'TEST' does not populate the 'reason' argument",
580        "type 'hello' should be renamed to 'Hello'",
581        "usage of directive 'deprecated' on type 'hello' does not populate the 'reason' argument",
582        "field 'Test' on type 'hello' should be renamed to 'test'",
583        "argument 'NAME' on field 'Test' on type 'hello' should be renamed to 'name'",
584        "type 'hello' should be renamed to 'Hello'",
585        "field 'GOODBYE' on type 'hello' should be renamed to 'goodbye'",
586    ]
587        .iter()
588        .for_each(|message| assert!(messages.contains(&message.to_string()), "expected '{message}' to be included in diagnostics"));
589
590    let schema = r#"
591        directive @withDeprecatedArgs(
592          arg: String @deprecated(reason: "Use `newArg`")
593          newArg: String
594        ) on FIELD
595
596        enum Lowercase {
597          AN_ENUM_MEMBER @deprecated(reason: "")
598        }
599
600        type Query {
601          __test: String,
602          hello(name: String!): Lowercase!
603        }
604
605        type Mutation {
606          __test: String,
607          hello(name: String!): Lowercase!
608        }
609
610        type Subscription {
611          __test: String,
612          hello(name: String!): Lowercase!
613        }
614
615        type Test {
616          name: String @deprecated(reason: "")
617        }
618
619        type Other {
620           name: string
621        }
622
623        scalar CustomScalar @specifiedBy(url: "https://specs.example.com/rfc1") @deprecated(reason: "")
624
625        union NewTest @deprecated(reason: "") = testType | typeTest
626
627        interface Game @deprecated(reason: "") {
628          title: String!
629          publisher: String! @deprecated(reason: "")
630        }
631
632        input Test @deprecated(reason: "") {
633          other: String @deprecated(reason: "")
634        }
635
636        type Hello @deprecated(reason: "") {
637          test(name: String): String
638        }
639
640        extend type Hello {
641          goodbye: String
642        }
643
644        schema {
645          query: Query
646          mutation: Mutation
647        }
648    "#;
649
650    let diagnostics = lint(schema).unwrap();
651
652    assert!(diagnostics.is_empty());
653}