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 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 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}