Skip to content

Commit

Permalink
Initial support for code actions
Browse files Browse the repository at this point in the history
  • Loading branch information
captbaritone committed Mar 26, 2024
1 parent 4f579f1 commit 0b53e3d
Show file tree
Hide file tree
Showing 24 changed files with 455 additions and 16 deletions.
27 changes: 27 additions & 0 deletions src/CodeActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as ts from "typescript";

export function prefixNode(node: ts.Node, prefix: string): ts.FileTextChanges {
const start = node.getStart();
return {
fileName: node.getSourceFile().fileName,
textChanges: [{ span: { start, length: 0 }, newText: prefix }],
};
}

export function removeNode(node: ts.Node): ts.FileTextChanges {
const start = node.getStart();
const length = node.getEnd() - start;
return {
fileName: node.getSourceFile().fileName,
textChanges: [{ span: { start, length }, newText: "" }],
};
}

export function replaceNode(node: ts.Node, newText): ts.FileTextChanges {
const start = node.getStart();
const length = node.getEnd() - start;
return {
fileName: node.getSourceFile().fileName,
textChanges: [{ span: { start, length }, newText }],
};
}
23 changes: 23 additions & 0 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function wrongCasingForGratsTag(actual: string, expected: string) {
return `Incorrect casing for Grats tag \`@${actual}\`. Use \`@${expected}\` instead.`;
}

// TODO: Add code action
export function invalidGratsTag(actual: string) {
const validTagList = ALL_TAGS.map((t) => `\`@${t}\``).join(", ");
return `\`@${actual}\` is not a valid Grats tag. Valid tags are: ${validTagList}.`;
Expand Down Expand Up @@ -145,6 +146,7 @@ export function typeTagOnAliasOfNonObjectOrUnknown() {
return `Expected \`@${TYPE_TAG}\` type to be an object type literal (\`{ }\`) or \`unknown\`. For example: \`type Foo = { bar: string }\` or \`type Query = unknown\`.`;
}

// TODO: Add code action
export function typeNameNotDeclaration() {
return `Expected \`__typename\` to be a property declaration. For example: \`__typename: "MyType"\`.`;
}
Expand Down Expand Up @@ -176,6 +178,7 @@ export function typeNameDoesNotMatchExpected(expected: string) {
return `Expected \`__typename\` property to be \`"${expected}"\`. ${TYPENAME_CONTEXT}`;
}

// TODO: Add code action
export function argumentParamIsMissingType() {
return "Expected GraphQL field arguments to have an explicit type annotation. If there are no arguments, you can use `args: unknown`. Grats needs to be able to see the type of the arguments to generate a GraphQL schema.";
}
Expand Down Expand Up @@ -236,6 +239,7 @@ export function expectedOneNonNullishType() {
return `Expected exactly one non-nullish type. GraphQL does not support fields returning an arbitrary union of types. Consider defining an explicit \`@${UNION_TAG}\` union type and returning that.`;
}

// TODO: Add code action
export function ambiguousNumberType() {
return `Unexpected number type. GraphQL supports both Int and Float, making \`number\` ambiguous. Instead, import the \`Int\` or \`Float\` type from \`${LIBRARY_IMPORT_NAME}\` and use that. e.g. \`import { Int, Float } from "${LIBRARY_IMPORT_NAME}";\`.`;
}
Expand Down Expand Up @@ -272,14 +276,17 @@ export function expectedNameIdentifier() {
return "Expected an name identifier. Grats expected to find a name here which it could use to derive the GraphQL name.";
}

// TODO: Add code action
export function killsParentOnExceptionWithWrongConfig() {
return `Unexpected \`@${KILLS_PARENT_ON_EXCEPTION_TAG}\` tag. \`@${KILLS_PARENT_ON_EXCEPTION_TAG}\` is only supported when the Grats config option \`nullableByDefault\` is enabled in your \`tsconfig.json\`.`;
}

// TODO: Add code action
export function killsParentOnExceptionOnNullable() {
return `Unexpected \`@${KILLS_PARENT_ON_EXCEPTION_TAG}\` tag on field typed as nullable. \`@${KILLS_PARENT_ON_EXCEPTION_TAG}\` will force a field to appear as non-nullable in the schema, so it's implementation must also be non-nullable. .`;
}

// TODO: Add code action
export function nonNullTypeCannotBeOptional() {
return `Unexpected optional argument that does not also accept \`null\`. Optional arguments in GraphQL may get passed an explicit \`null\` value by the GraphQL executor. This means optional arguments must be typed to also accept \`null\`. Consider adding \`| null\` to the end of the argument type.`;
}
Expand All @@ -294,10 +301,12 @@ export function mergedInterfaces() {
].join(" ");
}

// TODO: Add code action
export function implementsTagOnClass() {
return `\`@${IMPLEMENTS_TAG_DEPRECATED}\` has been deprecated. Instead use \`class MyType implements MyInterface\`.`;
}

// TODO: Add code action
export function implementsTagOnInterface() {
return `\`@${IMPLEMENTS_TAG_DEPRECATED}\` has been deprecated. Instead use \`interface MyType extends MyInterface\`.`;
}
Expand All @@ -306,6 +315,7 @@ export function implementsTagOnTypeAlias() {
return `\`@${IMPLEMENTS_TAG_DEPRECATED}\` has been deprecated. Types which implement GraphQL interfaces should be defined using TypeScript class or interface declarations.`;
}

// TODO: Add code action
export function duplicateTag(tagName: string) {
return `Unexpected duplicate \`@${tagName}\` tag. Grats does not accept multiple instances of the same tag.`;
}
Expand All @@ -314,13 +324,15 @@ export function duplicateInterfaceTag() {
return `Unexpected duplicate \`@${IMPLEMENTS_TAG_DEPRECATED}\` tag. To declare that a type or interface implements multiple interfaces list them as comma separated values: \`@${IMPLEMENTS_TAG_DEPRECATED} interfaceA, interfaceB\`.`;
}

// TODO: Add code action
export function parameterWithoutModifiers() {
return [
`Expected \`@${FIELD_TAG}\` constructor parameter to be a parameter property. This requires a modifier such as \`public\` or \`readonly\` before the parameter name.\n\n`,
`Learn more: ${DOC_URLS.parameterProperties}`,
].join("");
}

// TODO: Add code action
export function parameterPropertyNotPublic() {
return [
`Expected \`@${FIELD_TAG}\` parameter property to be public. Valid modifiers for \`@${FIELD_TAG}\` parameter properties are \`public\` and \`readonly\`.\n\n`,
Expand Down Expand Up @@ -377,22 +389,27 @@ export function graphQLTagNameHasWhitespace(tagName: string): string {
return `Expected text following a \`@${tagName}\` tag to be a GraphQL name. If you intended this text to be a description, place it at the top of the docblock before any \`@tags\`.`;
}

// TODO: Add code action
export function subscriptionFieldNotAsyncIterable() {
return "Expected fields on `Subscription` to return an `AsyncIterable`. Fields on `Subscription` model a subscription, which is a stream of events. Grats expects fields on `Subscription` to return an `AsyncIterable` which can be used to model this stream.";
}

// TODO: Add code action
export function operationTypeNotUnknown() {
return "Operation types `Query`, `Mutation`, and `Subscription` must be defined as type aliases of `unknown`. E.g. `type Query = unknown`. This is because GraphQL servers do not have an agreed upon way to produce root values, and Grats errs on the side of safety. If you are trying to implement dependency injection, consider using the `context` argument passed to each resolver instead. If you have a strong use case for a concrete root value, please file an issue.";
}

// TODO: Add code action
export function expectedNullableArgumentToBeOptional() {
return "Expected nullable argument to _also_ be optional (`?`). graphql-js may omit properties on the argument object where an undefined GraphQL variable is passed, or if the argument is omitted in the operation text. To ensure your resolver is capable of handing this scenario, add a `?` to the end of the argument name to make it optional. e.g. `{greeting?: string | null}`";
}

// TODO: Add code action
export function gqlTagInLineComment() {
return `Unexpected Grats tag in line (\`//\`) comment. Grats looks for tags in JSDoc-style block comments. e.g. \`/** @gqlType */\`. For more information see: ${DOC_URLS.commentSyntax}`;
}

// TODO: Add code action
export function gqlTagInNonJSDocBlockComment() {
return `Unexpected Grats tag in non-JSDoc-style block comment. Grats only looks for tags in JSDoc-style block comments which start with \`/**\`. For more information see: ${DOC_URLS.commentSyntax}`;
}
Expand All @@ -401,10 +418,12 @@ export function gqlTagInDetachedJSDocBlockComment() {
return `Unexpected Grats tag in detached docblock. Grats was unable to determine which TypeScript declaration this docblock is associated with. Moving the docblock to a position with is unambiguously "above" the relevant declaration may help. For more information see: ${DOC_URLS.commentSyntax}`;
}

// TODO: Add code action
export function gqlFieldTagOnInputType() {
return `The tag \`@${FIELD_TAG}\` is not needed on fields of input types. All fields are automatically included as part of the input type. This tag can be safely removed.`;
}

// TODO: Add code action
export function gqlFieldParentMissingTag() {
return `Unexpected \`@${FIELD_TAG}\`. The parent construct must be either a \`@${TYPE_TAG}\` or \`@${INTERFACE_TAG}\` tag. Are you missing one of these tags?`;
}
Expand All @@ -413,6 +432,7 @@ export function missingSpecifiedByUrl() {
return `Expected \`@${SPECIFIED_BY_TAG}\` tag to be followed by a URL. This URL will be used as the \`url\` argument to the \`@specifiedBy\` directive in the generated GraphQL schema. See https://spec.graphql.org/draft/#sec--specifiedBy for more information.`;
}

// TODO: Add code action
export function specifiedByOnWrongNode() {
return `Unexpected \`@${SPECIFIED_BY_TAG}\` tag on non-scalar declaration. \`@${SPECIFIED_BY_TAG}\` can only be used on custom scalar declarations. Are you missing a \`@${SCALAR_TAG}\` tag?`;
}
Expand Down Expand Up @@ -442,14 +462,17 @@ export function concreteTypeMissingTypename(implementor: string): string {
return `Missing \`__typename\` on \`${implementor}\`. The type \`${implementor}\` is used in a union or interface, so it must have a \`__typename\` field.`;
}

// TODO: Add code action
export function invalidFieldNonPublicAccessModifier(): string {
return `Unexpected access modifier on \`@${FIELD_TAG}\` method. GraphQL fields must be able to be called by the GraphQL executor.`;
}

// TODO: Add code action
export function invalidStaticModifier(): string {
return `Unexpected \`static\` modifier on non-method \`@${FIELD_TAG}\`. \`static\` is only valid on method signatures.`;
}

// TODO: Add code action
export function staticMethodOnNonClass(): string {
return `Unexpected \`@${FIELD_TAG}\` \`static\` method on non-class declaration. Static method fields may only be declared on exported class declarations.`;
}
Expand Down
43 changes: 36 additions & 7 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { relativePath } from "./gratsRoot";
import { ISSUE_URL } from "./Errors";
import { detectInvalidComments } from "./comments";
import { extend, loc } from "./utils/helpers";
import * as Act from "./CodeActions";

export const LIBRARY_IMPORT_NAME = "grats";
export const LIBRARY_NAME = "Grats";
Expand Down Expand Up @@ -183,7 +184,16 @@ class Extractor {
break;
case KILLS_PARENT_ON_EXCEPTION_TAG: {
if (!this.hasTag(node, FIELD_TAG)) {
this.report(tag.tagName, E.killsParentOnExceptionOnWrongNode());
this.report(
tag.tagName,
E.killsParentOnExceptionOnWrongNode(),
[],
{
fixName: "remove-kills-parent-on-exception",
description: "Remove @killsParentOnException tag",
changes: [Act.removeNode(tag)],
},
);
}
// TODO: Report invalid location as well
break;
Expand All @@ -198,16 +208,26 @@ class Extractor {
{
const lowerCaseTag = tag.tagName.text.toLowerCase();
if (lowerCaseTag.startsWith("gql")) {
let reported = false;
for (const t of ALL_TAGS) {
if (t.toLowerCase() === lowerCaseTag) {
this.report(
tag.tagName,
E.wrongCasingForGratsTag(tag.tagName.text, t),
[],
{
fixName: "fix-grats-tag-casing",
description: `Change to @${t}`,
changes: [Act.replaceNode(tag.tagName, t)],
},
);
reported = true;
break;
}
}
this.report(tag.tagName, E.invalidGratsTag(tag.tagName.text));
if (!reported) {
this.report(tag.tagName, E.invalidGratsTag(tag.tagName.text));
}
}
}
break;
Expand Down Expand Up @@ -291,8 +311,9 @@ class Extractor {
node: ts.Node,
message: string,
relatedInformation?: ts.DiagnosticRelatedInformation[],
fix?: ts.CodeFixAction,
): null {
this.errors.push(tsErr(node, message, relatedInformation));
this.errors.push(tsErr(node, message, relatedInformation, fix));
return null;
}

Expand Down Expand Up @@ -397,7 +418,11 @@ class Extractor {
});

if (!isExported) {
return this.report(classNode, E.staticMethodFieldClassNotExported());
return this.report(classNode, E.staticMethodFieldClassNotExported(), [], {
fixName: "add-export-keyword-to-class",
description: "Add export keyword to class with static @gqlField",
changes: [Act.prefixNode(classNode, "export ")],
});
}
const isDefault = classNode.modifiers?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.DefaultKeyword;
Expand Down Expand Up @@ -518,12 +543,16 @@ class Extractor {
if (node.name == null) {
return this.report(node, E.functionFieldNotNamed());
}
const exportKeyword = node.modifiers?.some((modifier) => {
const isExported = node.modifiers?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.ExportKeyword;
});

if (exportKeyword == null) {
return this.report(node.name, E.functionFieldNotNamedExport());
if (!isExported) {
return this.report(node.name, E.functionFieldNotNamedExport(), [], {
fixName: "add-export-keyword-to-function",
description: "Add export keyword to function with @gqlField",
changes: [Act.prefixNode(node, "export ")],
});
}
const defaultKeyword = node.modifiers?.find((modifier) => {
return modifier.kind === ts.SyntaxKind.DefaultKeyword;
Expand Down
7 changes: 7 additions & 0 deletions src/tests/codeActions/asyncFunctionFieldNotExported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @gqlField */
async function greet(_: Query): Promise<string> {
return "Hello, World!";
}

/** @gqlType */
export type Query = unknown;
23 changes: 23 additions & 0 deletions src/tests/codeActions/asyncFunctionFieldNotExported.ts.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-----------------
INPUT
-----------------
/** @gqlField */
async function greet(_: Query): Promise<string> {
return "Hello, World!";
}

/** @gqlType */
export type Query = unknown;

-----------------
OUTPUT
-----------------
- Expected
+ Received

@@ -1,5 +1,5 @@
/** @gqlField */
- async function greet(_: Query): Promise<string> {
+ export async function greet(_: Query): Promise<string> {
return "Hello, World!";
}
13 changes: 13 additions & 0 deletions src/tests/codeActions/classWithStaticMethodNotExported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @gqlType */
class User {
/** @gqlField */
name: string;

/** @gqlField */
static getUser(_: Query): User {
return new User();
}
}

/** @gqlType */
export type Query = unknown;
29 changes: 29 additions & 0 deletions src/tests/codeActions/classWithStaticMethodNotExported.ts.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-----------------
INPUT
-----------------
/** @gqlType */
class User {
/** @gqlField */
name: string;

/** @gqlField */
static getUser(_: Query): User {
return new User();
}
}

/** @gqlType */
export type Query = unknown;

-----------------
OUTPUT
-----------------
- Expected
+ Received

@@ -1,5 +1,5 @@
/** @gqlType */
- class User {
+ export class User {
/** @gqlField */
name: string;
7 changes: 7 additions & 0 deletions src/tests/codeActions/functionFieldNotExported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @gqlField */
function greet(_: Query): string {
return "Hello, World!";
}

/** @gqlType */
type Query = unknown;
23 changes: 23 additions & 0 deletions src/tests/codeActions/functionFieldNotExported.ts.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-----------------
INPUT
-----------------
/** @gqlField */
function greet(_: Query): string {
return "Hello, World!";
}

/** @gqlType */
type Query = unknown;

-----------------
OUTPUT
-----------------
- Expected
+ Received

@@ -1,5 +1,5 @@
/** @gqlField */
- function greet(_: Query): string {
+ export function greet(_: Query): string {
return "Hello, World!";
}
5 changes: 5 additions & 0 deletions src/tests/codeActions/killsParentOnExceptionWrongNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* @gqlType
* @killsParentOnException
*/
type Query = unknown;
Loading

0 comments on commit 0b53e3d

Please sign in to comment.