diff --git a/llm-docs/getting-started/cli.md b/llm-docs/getting-started/cli.md index e1681500..267b019e 100644 --- a/llm-docs/getting-started/cli.md +++ b/llm-docs/getting-started/cli.md @@ -55,30 +55,42 @@ Options: -h, --help display help for command Commands: - locate [options] + locate [options] ``` ## Locate -The `locate` command reports the location (file, line, column) at which a given type or field is defined in your code. `grats locate` can also be invoked by other tools. For example the click-to-definition feature of an GraphQL editor integration could use invoke this command to find the location of a type or field. +The `locate` command reports the location (file, line, column) at which a given schema element is defined in your code. It accepts a [Schema Coordinate](https://spec.graphql.org/draft/#sec-Schema-Coordinates) as its argument. + +`grats locate` can also be invoked by other tools. For example the click-to-definition feature of a GraphQL editor integration could invoke this command to find the location of a type or field. For example, Relay's VSCode Extension is [exploring](https://github.com/facebook/relay/pull/4434) adding the ability to leverage such a tool. ```bash +# Locate a named type +npx grats locate User + # Locate a field npx grats locate User.name -# Locate a named type -npx grats locate User +# Locate a field argument +npx grats locate "Query.user(id:)" + +# Locate an enum value +npx grats locate "MyEnum.VALUE" + +# Locate a directive +npx grats locate @deprecated ``` ### Options ```text -Usage: grats locate [options] +Usage: grats locate [options] Arguments: -ENTITY GraphQL entity to locate. E.g. `User` or `User.id` +COORDINATE Schema coordinate to locate. E.g. `User`, `User.name`, + `Query.user(id:)`, `@deprecated` Options: --tsconfig Path to tsconfig.json. Defaults to auto-detecting based on the current working directory diff --git a/src/Locate.ts b/src/Locate.ts index 92c97f46..e3ff63af 100644 --- a/src/Locate.ts +++ b/src/Locate.ts @@ -1,77 +1,115 @@ import { GraphQLSchema, Location, - isObjectType, - isInterfaceType, - isInputObjectType, + resolveSchemaCoordinate, + type ResolvedSchemaElement, } from "graphql"; import { Result, err, ok } from "./utils/Result.js"; import { nullThrows } from "./utils/helpers.js"; -type EntityName = { - parent: string; - field: string | null; -}; - /** - * Given an entity name of the format `ParentType` or `ParentType.fieldName`, - * locate the entity in the schema and return its location. + * Given a schema coordinate string, locate the entity in the schema + * and return its source location. + * + * Uses the Schema Coordinates spec: + * https://spec.graphql.org/draft/#sec-Schema-Coordinates + * + * Supports all schema coordinate forms: + * - `Type` — named type + * - `Type.field` — field on object/interface type + * - `Type.field(arg:)` — field argument + * - `EnumType.VALUE` — enum value + * - `InputType.field` — input field + * - `@directive` — directive + * - `@directive(arg:)` — directive argument */ export function locate( schema: GraphQLSchema, - entityName: string, + coordinate: string, ): Result { - const entityResult = parseEntityName(entityName); - if (entityResult.kind === "ERROR") { - return entityResult; - } - const entity = entityResult.value; - const type = schema.getType(entity.parent); - if (type == null) { - return err(`Cannot locate type \`${entity.parent}\`.`); - } - if (entity.field == null) { - if (type.astNode == null) { - throw new Error( - `Grats bug: Cannot find location of type \`${entity.parent}\`.`, - ); - } - return ok(nullThrows(type.astNode.name.loc)); - } - - if ( - !(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) - ) { + let resolved: ResolvedSchemaElement | undefined; + try { + resolved = resolveSchemaCoordinate(schema, coordinate); + } catch (e: unknown) { return err( - `Cannot locate field \`${entity.field}\` on type \`${entity.parent}\`. Only object types, interfaces, and input objects have fields.`, + `Invalid schema coordinate: \`${coordinate}\`. ${e instanceof Error ? e.message : String(e)}`, ); } - - const field = type.getFields()[entity.field]; - if (field == null) { - return err( - `Cannot locate field \`${entity.field}\` on type \`${entity.parent}\`.`, - ); - } - - if (field.astNode == null) { - throw new Error( - `Grats bug: Cannot find location of field \`${entity.field}\` on type \`${entity.parent}\`.`, - ); + if (resolved == null) { + return err(`Could not resolve schema coordinate: \`${coordinate}\`.`); } - return ok(nullThrows(field.astNode.name.loc)); -} - -const ENTITY_NAME_REGEX = /^([A-Za-z0-9_]+)(?:\.([A-Za-z0-9_]+))?$/; -function parseEntityName(entityName: string): Result { - const match = ENTITY_NAME_REGEX.exec(entityName); - if (match == null) { - return err( - `Invalid entity name: \`${entityName}\`. Expected \`ParentType\` or \`ParentType.fieldName\`.`, - ); + switch (resolved.kind) { + case "NamedType": { + const astNode = resolved.type.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of type in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "Field": { + const astNode = resolved.field.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of field in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "InputField": { + const astNode = resolved.inputField.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of input field in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "EnumValue": { + const astNode = resolved.enumValue.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of enum value in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "FieldArgument": { + const astNode = resolved.fieldArgument.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of field argument in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "Directive": { + const astNode = resolved.directive.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of directive in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + case "DirectiveArgument": { + const astNode = resolved.directiveArgument.astNode; + if (astNode == null) { + throw new Error( + `Grats bug: Cannot find location of directive argument in coordinate \`${coordinate}\`.`, + ); + } + return ok(nullThrows(astNode.name.loc)); + } + default: { + // Exhaustive check — if new schema coordinate kinds are added, + // TypeScript will catch this. + const _exhaustive: never = resolved; + throw new Error( + `Grats bug: Unexpected schema coordinate kind: ${(resolved as { kind: string }).kind}`, + ); + } } - const parent = match[1]; - const field = match[2] || null; - return ok({ parent, field }); } diff --git a/src/cli.ts b/src/cli.ts index 6da10b2b..048b994f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,7 +78,10 @@ program program .command("locate") - .argument("", "GraphQL entity to locate. E.g. `User` or `User.id`") + .argument( + "", + "Schema coordinate to locate. E.g. `User`, `User.name`, `Query.user(id:)`, `@deprecated`", + ) .option( "--tsconfig ", "Path to tsconfig.json. Defaults to auto-detecting based on the current working directory", diff --git a/src/tests/fixtures/locate/enumValue.invalid.ts b/src/tests/fixtures/locate/enumValue.invalid.ts new file mode 100644 index 00000000..53bd1f60 --- /dev/null +++ b/src/tests/fixtures/locate/enumValue.invalid.ts @@ -0,0 +1,6 @@ +// Locate: Greeting.HELLO +/** @gqlEnum */ +export enum Greeting { + HELLO = "HELLO", + GOODBYE = "GOODBYE", +} diff --git a/src/tests/fixtures/locate/enumValue.invalid.ts.expected.md b/src/tests/fixtures/locate/enumValue.invalid.ts.expected.md new file mode 100644 index 00000000..ca3c72b5 --- /dev/null +++ b/src/tests/fixtures/locate/enumValue.invalid.ts.expected.md @@ -0,0 +1,23 @@ +# locate/enumValue.invalid.ts + +## Input + +```ts title="locate/enumValue.invalid.ts" +// Locate: Greeting.HELLO +/** @gqlEnum */ +export enum Greeting { + HELLO = "HELLO", + GOODBYE = "GOODBYE", +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/locate/enumValue.invalid.ts:4:11 - error: Located here + +4 HELLO = "HELLO", + ~~~~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/locate/fieldArgument.invalid.ts b/src/tests/fixtures/locate/fieldArgument.invalid.ts new file mode 100644 index 00000000..7acadf58 --- /dev/null +++ b/src/tests/fixtures/locate/fieldArgument.invalid.ts @@ -0,0 +1,8 @@ +// Locate: Query.greeting(salutation:) +/** @gqlType */ +type Query = unknown; + +/** @gqlQueryField */ +export function greeting(salutation: string): string { + return `${salutation}, world!`; +} diff --git a/src/tests/fixtures/locate/fieldArgument.invalid.ts.expected.md b/src/tests/fixtures/locate/fieldArgument.invalid.ts.expected.md new file mode 100644 index 00000000..45fbc7ba --- /dev/null +++ b/src/tests/fixtures/locate/fieldArgument.invalid.ts.expected.md @@ -0,0 +1,25 @@ +# locate/fieldArgument.invalid.ts + +## Input + +```ts title="locate/fieldArgument.invalid.ts" +// Locate: Query.greeting(salutation:) +/** @gqlType */ +type Query = unknown; + +/** @gqlQueryField */ +export function greeting(salutation: string): string { + return `${salutation}, world!`; +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/locate/fieldArgument.invalid.ts:6:26 - error: Located here + +6 export function greeting(salutation: string): string { + ~~~~~~~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected.md b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected.md index b142adad..f7164924 100644 --- a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected.md +++ b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected.md @@ -13,5 +13,5 @@ export type Date = string; ### Error Locating Type ```text -Cannot locate field `name` on type `Date`. Only object types, interfaces, and input objects have fields. +Invalid schema coordinate: `Date.name`. Expected "Date" to be an Enum, Input Object, Object or Interface type. ``` \ No newline at end of file diff --git a/src/tests/fixtures/locate/malformedEntitySyntax.invalid.ts.expected.md b/src/tests/fixtures/locate/malformedEntitySyntax.invalid.ts.expected.md index e03b62f8..f62e732b 100644 --- a/src/tests/fixtures/locate/malformedEntitySyntax.invalid.ts.expected.md +++ b/src/tests/fixtures/locate/malformedEntitySyntax.invalid.ts.expected.md @@ -16,5 +16,5 @@ type User = { ### Error Locating Type ```text -Invalid entity name: `User->name`. Expected `ParentType` or `ParentType.fieldName`. +Invalid schema coordinate: `User->name`. Syntax Error: Invalid character: "-". ``` \ No newline at end of file diff --git a/src/tests/fixtures/locate/notFoundField.invalid.ts.expected.md b/src/tests/fixtures/locate/notFoundField.invalid.ts.expected.md index f0f00d41..1a4dbf06 100644 --- a/src/tests/fixtures/locate/notFoundField.invalid.ts.expected.md +++ b/src/tests/fixtures/locate/notFoundField.invalid.ts.expected.md @@ -16,5 +16,5 @@ type User = { ### Error Locating Type ```text -Cannot locate field `not_a_field` on type `User`. +Could not resolve schema coordinate: `User.not_a_field`. ``` \ No newline at end of file diff --git a/src/tests/fixtures/locate/notFoundType.invalid.ts.expected.md b/src/tests/fixtures/locate/notFoundType.invalid.ts.expected.md index 540f8583..1bf7d120 100644 --- a/src/tests/fixtures/locate/notFoundType.invalid.ts.expected.md +++ b/src/tests/fixtures/locate/notFoundType.invalid.ts.expected.md @@ -16,5 +16,5 @@ type User = { ### Error Locating Type ```text -Cannot locate type `WhoopsNotARealType`. +Could not resolve schema coordinate: `WhoopsNotARealType`. ``` \ No newline at end of file diff --git a/website/docs/01-getting-started/02-cli.md b/website/docs/01-getting-started/02-cli.md index 480a36d7..44490e07 100644 --- a/website/docs/01-getting-started/02-cli.md +++ b/website/docs/01-getting-started/02-cli.md @@ -56,30 +56,42 @@ Options: -h, --help display help for command Commands: - locate [options] + locate [options] ``` ## Locate -The `locate` command reports the location (file, line, column) at which a given type or field is defined in your code. `grats locate` can also be invoked by other tools. For example the click-to-definition feature of an GraphQL editor integration could use invoke this command to find the location of a type or field. +The `locate` command reports the location (file, line, column) at which a given schema element is defined in your code. It accepts a [Schema Coordinate](https://spec.graphql.org/draft/#sec-Schema-Coordinates) as its argument. + +`grats locate` can also be invoked by other tools. For example the click-to-definition feature of a GraphQL editor integration could invoke this command to find the location of a type or field. For example, Relay's VSCode Extension is [exploring](https://github.com/facebook/relay/pull/4434) adding the ability to leverage such a tool. ```bash +# Locate a named type +npx grats locate User + # Locate a field npx grats locate User.name -# Locate a named type -npx grats locate User +# Locate a field argument +npx grats locate "Query.user(id:)" + +# Locate an enum value +npx grats locate "MyEnum.VALUE" + +# Locate a directive +npx grats locate @deprecated ``` ### Options ``` -Usage: grats locate [options] +Usage: grats locate [options] Arguments: -ENTITY GraphQL entity to locate. E.g. `User` or `User.id` +COORDINATE Schema coordinate to locate. E.g. `User`, `User.name`, + `Query.user(id:)`, `@deprecated` Options: --tsconfig Path to tsconfig.json. Defaults to auto-detecting based on the current working directory