├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── README.md ├── images └── icon.png ├── jest.config.js ├── language ├── emptyJsonSchema.json ├── language-configuration.json └── rescriptRelayRouterLanguage-configuration.json ├── package.json ├── src ├── ReScriptEditorSupport.ts ├── __tests__ │ ├── __snapshots__ │ │ ├── extractToFragment.test.ts.snap │ │ └── findGraphQLSources.test.ts.snap │ ├── contextUtils.test.ts │ ├── extensionUtils.test.ts │ ├── extractToFragment.test.ts │ ├── findGraphQLSources.test.ts │ ├── graphqlUtils.test.ts │ └── hasHighEnoughReScriptRelayVersion.ts ├── addGraphQLComponent.ts ├── comby.ts ├── configUtils.ts ├── contextUtils.ts ├── contextUtilsNoVscode.ts ├── createNewFragmentComponentsUtils.ts ├── extension.ts ├── extensionTypes.ts ├── extensionUtils.ts ├── extensionUtilsNoVscode.ts ├── findGraphQLSources.ts ├── graphqlConfig.ts ├── graphqlUtils.ts ├── graphqlUtilsNoVscode.ts ├── loadSchema.ts ├── lspUtils.ts ├── relayDirectives.ts ├── schemaLoaders.ts ├── server.ts ├── testfixture │ └── graphqlSources.res ├── utils.ts └── utilsNoVsCode.ts ├── syntaxes ├── graphql.json ├── graphql.res.json └── rescriptRelayRouter.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CI: true 11 | 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - uses: actions/cache@v2 24 | with: 25 | path: "node_modules" 26 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 27 | - name: Install project 28 | run: | 29 | yarn install --frozen-lockfile 30 | - name: Build 31 | run: | 32 | yarn build 33 | - name: Test 34 | run: | 35 | yarn test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | node_modules 3 | build 4 | out 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}" 12 | ], 13 | "outFiles": ["${workspaceRoot}/src/**/*.ts"] 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build:watch", 7 | "group": "build", 8 | "presentation": { 9 | "panel": "dedicated", 10 | "reveal": "never" 11 | }, 12 | "problemMatcher": [ 13 | "$tsc" 14 | ] 15 | }, 16 | { 17 | "type": "npm", 18 | "script": "watch", 19 | "isBackground": true, 20 | "group": { 21 | "kind": "build", 22 | "isDefault": true 23 | }, 24 | "presentation": { 25 | "panel": "dedicated", 26 | "reveal": "never" 27 | }, 28 | "problemMatcher": [ 29 | "$tsc-watch" 30 | ] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | *.vsix 2 | src 3 | public 4 | jest.config.js 5 | .env 6 | tsconfig* 7 | tslint* 8 | *.log 9 | **/__tests__/** 10 | out 11 | .vscode 12 | .gitignore 13 | webpack.config.js 14 | node_modules -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## main 2 | 3 | ## 0.11.3 4 | 5 | - More `RescriptRelayRouter` related tooling. 6 | 7 | ## 0.11.2 8 | 9 | - More `RescriptRelayRouter` related tooling. 10 | 11 | ## 0.11.1 12 | 13 | - Fix bug in experimental editor tooling for RescriptRelayRouter. 14 | 15 | ## 0.11.0 16 | 17 | - Experimental editor tooling support for the unreleased RescriptRelayRouter. 18 | 19 | ## 0.10.0 20 | 21 | - Use the official Relay LSP when available in a project (essentially for RescriptRelay versions `>1.0.0-beta.20`). 22 | - Remove support for running the compiler automatically. This never worked well enough, and often resulted in weird ghost processes etc. And it's not needed in the same way now that the official Relay LSP is used when available. 23 | 24 | ## 0.9.3 25 | 26 | - Fix fragment name generator generating illegal names in some cases. 27 | - Support loading `relay.config.cjs` files. 28 | 29 | ## 0.9.2 30 | 31 | - Add code action for opening source file for fragment spread. 32 | 33 | ## 0.9.1 34 | 35 | - Add monorepo support. (@tsnobip) 36 | 37 | ## 0.9.0 38 | 39 | - Check if watchman is installed and warn if not. (@mellson) 40 | 41 | ## 0.8.0 42 | 43 | - Trigger completion of fragment components on dot-based autocompletes. 44 | - More elborate control over how new fragment components are named. 45 | - Various bug fixes for the `@connection` code action. 46 | 47 | ## 0.7.0 48 | 49 | - Enable all experimental features by default, and put most of them behind toggleable settings (that are on by default). 50 | 51 | ## 0.6.2 52 | 53 | - Detailed hover for `dataId`, and autocomplete (via pipe) `someDataId->RescriptRelay.dataIdToString` when possible. 54 | - Jump-to-definition for fragment spreads in GraphQL operations. 55 | - Never emit `_` in generated module names, as it clashes with what Relay expects. 56 | 57 | ## 0.6.1 58 | 59 | - Code action for adding `@connection`. 60 | - Look up GraphQL between files to be able to provide (some) hover info when a type isn't accessed in its original file. 61 | - Enable new experimental features for queries, mutations and subscriptions. 62 | 63 | ## 0.6.0 64 | 65 | - A ton of new features, currently hidden by a "experimental features" settings in the extension settings. Enable it and try them out ;) Documentation etc will follow in the upcoming releases as the experiemental features stabilize. 66 | 67 | ## 0.5.5 68 | 69 | - Fix issues with the extension accidentally creating Relay compiler processes that it doesn't also shut down properly. Shout out to @mellson. 70 | 71 | ## 0.5.4 72 | 73 | - The extension is now bundled properly via `esbuild`. 74 | - Add config for preferring short names. Adding a fragment on `TodoItem` in `SomeFile.res` now names the fragment `SomeFile_item` instead of `SomeFile_todoItem`, which tends to get quite long for types with long names. 75 | - Add command for creating a new file with a fragment in it. 76 | 77 | ## 0.5.3 78 | 79 | - Fix ghost errors caused by the extension adding erronous GraphQL to the schema (invalid wrt the spec, but valid in Relay). 80 | 81 | ## 0.5.2 82 | 83 | - Schema changes are now also automatically picked up for code actions. 84 | - `rescript-relay` can now be detected in `peerDependencies` as well (thanks @mellson). 85 | - Remove a few lingering references to ReasonRelay. 86 | 87 | ## 0.5.1 88 | 89 | - Add command to create lazy loaded version of current component. 90 | 91 | ## 0.5.0 92 | 93 | - Add code action for adding variable to operation definition. 94 | - Add explicit extra stop button when the Relay compiler has an error. 95 | 96 | ## 0.4.0 97 | 98 | - Make it work with the officially released `rescript-relay` package. 99 | - Remove `vscode-graphiql-explorer` integration. 100 | - Add option to add query boilerplate for preloaded query. 101 | - Add step for selecting what type to expand when generating a query that uses the node interface/node top level field. 102 | - Add step for autogenerating any needed variables etc when codegenning mutations. 103 | - Add code actions for adding `@appendNode/prependNode/appendEdge/prependEdge/deleteRecord/deleteEdge`. 104 | - Fragments can now easily be added to the root operation/fragment itself, as well as on interfaces and unions. 105 | 106 | ## 0.3.6 107 | 108 | - Fix up detection of the ReScriptRelay version. 109 | 110 | ## 0.3.5 111 | 112 | - Small bug fix, make the GraphQL LSP work again. 113 | 114 | ## 0.3.4 115 | 116 | - Small bug fix. 117 | 118 | ## 0.3.3 119 | 120 | - Print error when detecting ReasonRelay/ReScriptRelay versions that aren't high enough to support this extension. 121 | 122 | ## 0.3.1 123 | 124 | - Adding fragments/queries/mutations/subscriptions is now done properly through ASTs, which will increase the stability of using them quite a lot. 125 | 126 | ## 0.3.0 127 | 128 | - Automatically restart RLS (if it's installed) whenever the Relay compiler has changed generated files. This works around the issue where RLS does not pick up that the Relay compiler has emitted new files, or changed existing generated files. 129 | - Run the Relay compiler through VSCode automatically, and report errors discovered by the compiler inside VSCode. 130 | - Refresh the project whenever `relay.confg.js` changes. 131 | - Settings page added. 132 | 133 | ## 0.2.1 134 | 135 | - Update README with a list of the current features of the extension. 136 | 137 | ## 0.2.0 138 | 139 | - Restore autocomplete functionality again, that broke somewhere along the way. 140 | - Fix potential with fragment component generation from types with lower cased names. 141 | 142 | ## 0.1.0 143 | 144 | - Initial release. 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-rescript-relay 2 | 3 | Improve quality-of-life of using RescriptRelay with VSCode. **This requires RescriptRelay of version >= 0.13.0 and that you only use ReScript syntax with RescriptRelay.** 4 | 5 | Please report any issues you might have to the main [RescriptRelay issue tracker](https://github.com/zth/rescript-relay/issues). 6 | 7 | ## Setup 8 | 9 | Right now, you'll need to open VSCode in the _root where you've installed Relay_ and that folder must be part of a Git Repository. For monorepos, this means you'll need to open the subfolder where your frontend project using Relay is located. This will hopefully be fixed in the future. You'll need to have [watchman](https://facebook.github.io/watchman/docs/install.html) installed for the relay compiler to be able to run automatically. 10 | 11 | This extension should _Just Work(tm)_, as it finds and uses your `relay.config.js`. 12 | 13 | ## Features 14 | 15 | ### General 16 | 17 | - Syntax highlighting for GraphQL in ReScript. 18 | - Autocomplete and validations for your GraphQL operations using the official Relay LSP (or GraphQL Language Server if not available). 19 | - Automatically formatting all GraphQL operations in your documents on save using `prettier`. 20 | - Project is refreshed and recompiled whenever `relay.config.js` changes. 21 | - A ton of codegen and automatic GraphQL refactoring is supported. 22 | - Easily generate new components, extract existing selections to new fragment components, etc. 23 | - Autocomplete-and-insert unselected GraphQL magically from the autocomplete list. 24 | - Autocompleting fragment component JSX from values containing fragment spreads. 25 | 26 | ### Code generation 27 | 28 | Provides commands to generate boilerplate for `fragments`, `queries`, `mutations` and `subscriptions` via the commands: 29 | 30 | - `> Add fragment` 31 | - `> Add query` 32 | - `> Add mutation` 33 | - `> Add subscription` 34 | 35 | The added GraphQL definition can also automatically be edited in GraphiQL using the `vscode-graphiql-explorer` extension if that is installed. 36 | 37 | ### Enhanced autocompletion 38 | 39 | #### Unselected GraphQL fields 40 | 41 | You'll get autocomplete for any field that you _haven't_ selected in your fragments, in addition to those you have already selected. When selecting a previously unselected field via autocomplete, the selection for that field is automatically inserted into the target fragment. 42 | 43 | #### Available fragment components 44 | 45 | If there's a variable in scope of your autocompletion that has fragments spread on it, you'll get autocompletion for those fragment spreads. Selecting a fragment spread will insert a best-effort JSX snippet of that fragment component. Example: 46 | 47 | Autocompleting `todoItem`, where `todoItem` has `...Avatar_user` spread on it, would give an item in the autocomplete called `todoItem: Avatar_user`. Selecting that item would insert ``. 48 | 49 | #### `getConnectionNodes` 50 | 51 | Autocompleting on a pipe (`->`) on a connection prop which is annotated with `@connection` will suggest the autogenerated helper `FragmentModuleName.getConnectionNodes` that'll automatically turn your connection into an array of non-optional nodes. 52 | 53 | #### `dataIdToString` 54 | 55 | Autocompleting on a pipe of a `dataId` prop will suggest `RescriptRelay.dataIdToString` for converting a `dataId` to a `string`. 56 | 57 | ### Enhanced hover 58 | 59 | #### Schema documentation and definitions 60 | 61 | Enhanced hover information with GraphQL schema documentation, links to the current GraphQL type in the schema, and links to the actual definition of the source operation. 62 | 63 | ### Code actions 64 | 65 | #### Add new fragment component at value 66 | 67 | Automatically add a new fragment component via a code action on ReScript values. Example: Run code actions on `todoItem`, which is the value for a fragment on `TodoItem`, and get a code action for automatically inserting a new fragment on the fragment for `todoItem`, create that new fragment component, and copy the JSX to use the new component. Everything wired together automatically. 68 | 69 | ### Relay GraphQL Code actions 70 | 71 | #### Add new fragment component at location in GraphQL operation 72 | 73 | Inside any GraphQL definition, put your cursor on a field, activate code actions and choose `Add new fragment component here`. This will create a new component with a fragment and add that fragment next to your cursor. 74 | 75 | #### Extract field selections to new fragment component 76 | 77 | Inside any GraphQL definition, select any number of fields, activate code actions and choose `Extract selection to fragment component`. This will take your field selection and create a new component with a fragment including your selected fields. The fields you selected can optionally be removed from where they were extracted too if wanted, and the newly created fragment will be spread where you extracted the fields, setting up everything needed for you automatically. 78 | 79 | #### Automatically set up fragment for pagination 80 | 81 | Place your cursor on a `connection` field (basically a field of any GraphQL type that ends with `Connection`). Activate code actions, and select `Set up pagination for fragment`. This will setup all needed directives on your fragment to enable pagination. 82 | 83 | #### Add `@connection` 84 | 85 | Same as automatically setting up pagination, but only adding the `@connection` directive. 86 | 87 | #### Expand union and interface members 88 | 89 | Place your cursor on any field name of a field that's a union or interface, activate code actions, and select `Expand union/interface members`. All union/interface members will be expanded, including selecting its first field. 90 | 91 | #### Make fragment refetchable 92 | 93 | With the cursor in a fragment definition, activate code actions and select `Make fragment refetchable`. This will add the `@refetchable` directive to the fragment, with a suitable `queryName` preconfigured, making it possible to refetch the fragment. 94 | 95 | #### Add variable to operation definition 96 | 97 | In a query, mutation or subrscription, add a variable to any argument for a field, like `myField @include(if: $showMyField)`. Put your cursor on the variable name `$showMyField` and activate code actions. Select `Add variable to operation`. The variable is added to the operation, like `mutation SomeMutation($text: String!)`. 98 | 99 | #### Add variable to `@argumentDefinitions` 100 | 101 | In a fragment, add a variable to any argument for a field, like `myField @include(if: $showMyField)`. Put your cursor on the variable name `$showMyField` and activate code actions. Select `Add variable to @argumentDefinitions`. The variable is added to the fragment's `@argumentDefinitions`, like `fragment SomeFragment_user on User @argumentDefinitions(showMyField: {type: "Boolean" })`. 102 | 103 | #### Make fragment plural 104 | 105 | With the cursor in a fragment definition, activate code actions and select `Make fragment plural`. The Relay directive for saying that a fragment is plural ıs added to the fragment definition. 106 | 107 | #### Make fragment inline 108 | 109 | With the cursor in a fragment definition, activate code actions and select `Make fragment inline`. The Relay directive for saying that this fragment should _always be unmasked wherever spread_ is added to the fragment. 110 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zth/vscode-rescript-relay/4a9eab71c74c3e47cc267cfdd1df63fc73ec7f74/images/icon.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /language/emptyJsonSchema.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /language/language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#", 4 | "blockComment": ["\"\"\"", "\"\"\""] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"] 10 | ], 11 | "autoClosingPairs": [ 12 | ["{", "}"], 13 | ["[", "]"], 14 | ["(", ")"], 15 | { "open": "\"", "close": "\"", "notIn": ["string", "comment"] }, 16 | ["'", "'"] 17 | ], 18 | "surroundingPairs": [ 19 | ["{", "}"], 20 | ["[", "]"], 21 | ["(", ")"], 22 | ["\"", "\""], 23 | ["'", "'"] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /language/rescriptRelayRouterLanguage-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": [ "/*", "*/" ] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"] 9 | ], 10 | "autoClosingPairs": [ 11 | { "open": "{", "close": "}", "notIn": ["string"] }, 12 | { "open": "[", "close": "]", "notIn": ["string"] }, 13 | { "open": "(", "close": ")", "notIn": ["string"] }, 14 | { "open": "'", "close": "'", "notIn": ["string"] }, 15 | { "open": "\"", "close": "\"", "notIn": ["string", "comment"] }, 16 | { "open": "`", "close": "`", "notIn": ["string", "comment"] } 17 | ], 18 | "wordPattern": "(\"(?:[^\\\\\\\"]*(?:\\\\.)?)*\"?)|[^\\s{}\\[\\],:]+", 19 | "indentationRules": { 20 | "increaseIndentPattern": "({+(?=([^\"]*\"[^\"]*\")*[^\"}]*$))|(\\[+(?=([^\"]*\"[^\"]*\")*[^\"\\]]*$))", 21 | "decreaseIndentPattern": "^\\s*[}\\]],?\\s*$" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-rescript-relay", 3 | "description": "Improve quality-of-life of using RescriptRelay with VSCode.", 4 | "version": "0.11.3", 5 | "main": "./build/extension.js", 6 | "engines": { 7 | "vscode": "^1.62.0" 8 | }, 9 | "scripts": { 10 | "vscode:prepublish": "yarn build", 11 | "build:watch": "tsc -w", 12 | "test": "jest", 13 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=build/extension.js --external:vscode --format=cjs --platform=node", 14 | "build:extension": "esbuild ./src/extension.ts --bundle --outfile=build/extension.js --external:vscode --format=cjs --platform=node --sourcemap", 15 | "build:server": "esbuild ./src/server.ts --bundle --outfile=build/server.js --external:vscode --format=cjs --platform=node --sourcemap", 16 | "build:clean": "rm -rf build", 17 | "build": "yarn build:clean && yarn build:extension && yarn build:server" 18 | }, 19 | "author": "Gabriel Nordeborn ", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/zth/rescript-relay" 23 | }, 24 | "publisher": "GabrielNordeborn", 25 | "activationEvents": [ 26 | "workspaceContains:**/relay.config.{js,cjs,mjs}" 27 | ], 28 | "categories": [ 29 | "Other" 30 | ], 31 | "icon": "images/icon.png", 32 | "galleryBanner": { 33 | "color": "#171E26", 34 | "theme": "dark" 35 | }, 36 | "license": "MIT", 37 | "contributes": { 38 | "configuration": { 39 | "title": "RescriptRelay", 40 | "properties": { 41 | "rescript-relay.useNativeRelayLsp": { 42 | "type": "boolean", 43 | "default": true, 44 | "description": "Enable the native Relay LSP (instead of the official GraphQL LSP) if available" 45 | }, 46 | "rescript-relay.enableRescriptRelayRouterLsp": { 47 | "type": "boolean", 48 | "default": true, 49 | "description": "Enable editor tooling for RescriptRelayRouter if the router is used" 50 | }, 51 | "rescript-relay.preferShortNames": { 52 | "type": "boolean", 53 | "default": true, 54 | "description": "Prefer short names for generated fragments?" 55 | }, 56 | "rescript-relay.autocompleteUnselectedGraphQLFields": { 57 | "type": "boolean", 58 | "default": true, 59 | "description": "Enable autocomplete/insertion of unselected GraphQL fields" 60 | }, 61 | "rescript-relay.contextualCompletions": { 62 | "type": "boolean", 63 | "default": true, 64 | "description": "Enable RescriptRelay specific autocompletions" 65 | }, 66 | "rescript-relay.contextualHoverInfo": { 67 | "type": "boolean", 68 | "default": true, 69 | "description": "Enable RescriptRelay specific hover info" 70 | }, 71 | "graphql-config.load.rootDir": { 72 | "type": [ 73 | "string" 74 | ], 75 | "description": "Folder that contains relay.config.js (defaults to root)" 76 | } 77 | } 78 | }, 79 | "commands": [ 80 | { 81 | "command": "vscode-rescript-relay.add-fragment", 82 | "title": "Add fragment", 83 | "category": "RescriptRelay", 84 | "when": "editorLangId == rescript" 85 | }, 86 | { 87 | "command": "vscode-rescript-relay.add-file-with-fragment", 88 | "title": "Add new file + fragment", 89 | "category": "RescriptRelay", 90 | "when": "editorLangId == rescript" 91 | }, 92 | { 93 | "command": "vscode-rescript-relay.add-query", 94 | "title": "Add query", 95 | "category": "RescriptRelay", 96 | "when": "editorLangId == rescript" 97 | }, 98 | { 99 | "command": "vscode-rescript-relay.add-mutation", 100 | "title": "Add mutation", 101 | "category": "RescriptRelay", 102 | "when": "editorLangId == rescript" 103 | }, 104 | { 105 | "command": "vscode-rescript-relay.add-subscription", 106 | "title": "Add subscription", 107 | "category": "RescriptRelay", 108 | "when": "editorLangId == rescript" 109 | }, 110 | { 111 | "command": "vscode-rescript-relay.wrap-in-suspense-boundary", 112 | "title": "Wrap in suspense boundary", 113 | "category": "RescriptRelay", 114 | "when": "editorLangId == rescript" 115 | }, 116 | { 117 | "command": "vscode-rescript-relay.add-lazy-variant-of-component", 118 | "title": "Create lazy loaded version of component", 119 | "category": "RescriptRelay", 120 | "when": "editorLangId == rescript" 121 | }, 122 | { 123 | "command": "vscode-rescript-relay.wrap-in-suspense-list", 124 | "title": "Wrap in suspense list", 125 | "category": "RescriptRelay", 126 | "when": "editorLangId == rescript" 127 | }, 128 | { 129 | "command": "vscode-rescript-relay.router-use-query-params", 130 | "title": "Add useQueryParams for route file belongs to", 131 | "category": "RescriptRelayRouter", 132 | "when": "editorLangId == rescript" 133 | }, 134 | { 135 | "command": "vscode-rescript-relay.router-match-url", 136 | "title": "Find routes from URL", 137 | "category": "RescriptRelayRouter" 138 | } 139 | ], 140 | "languages": [ 141 | { 142 | "id": "graphql", 143 | "extensions": [ 144 | ".gql", 145 | ".graphql", 146 | ".graphqls" 147 | ], 148 | "aliases": [ 149 | "GraphQL" 150 | ], 151 | "configuration": "./language/language-configuration.json" 152 | }, 153 | { 154 | "id": "rescriptRelayRouter", 155 | "filenamePatterns": [ 156 | "**/*Routes.json", 157 | "**/routes.json" 158 | ], 159 | "configuration": "./language/rescriptRelayRouterLanguage-configuration.json" 160 | } 161 | ], 162 | "grammars": [ 163 | { 164 | "language": "graphql", 165 | "scopeName": "source.graphql", 166 | "path": "./syntaxes/graphql.json" 167 | }, 168 | { 169 | "injectTo": [ 170 | "source.rescript" 171 | ], 172 | "scopeName": "inline.graphql.rescript", 173 | "path": "./syntaxes/graphql.res.json", 174 | "embeddedLanguages": { 175 | "meta.embedded.block.graphql": "graphql" 176 | } 177 | }, 178 | { 179 | "language": "rescriptRelayRouter", 180 | "scopeName": "source.rescriptRelayRouter", 181 | "path": "./syntaxes/rescriptRelayRouter.json" 182 | } 183 | ] 184 | }, 185 | "dependencies": { 186 | "@types/lru-cache": "^5.1.1", 187 | "cosmiconfig": "7.0.1", 188 | "graphql": "15.8.0", 189 | "graphql-config": "3.3.0", 190 | "graphql-language-service-interface": "2.8.4", 191 | "graphql-language-service-parser": "1.9.2", 192 | "graphql-language-service-server": "2.6.3", 193 | "graphql-language-service-types": "1.8.2", 194 | "graphql-language-service-utils": "2.5.3", 195 | "kind-of": "6.0.3", 196 | "line-reader": "^0.4.0", 197 | "locate-character": "2.0.5", 198 | "lru-cache": "^6.0.0", 199 | "pascal-case": "3.1.2", 200 | "prettier": "2.2.1", 201 | "semver": "7.3.4", 202 | "tree-kill": "1.2.2", 203 | "typescript": "4.2.3", 204 | "vscode-languageclient": "7.0.0", 205 | "vscode-languageserver-protocol": "3.16.0" 206 | }, 207 | "devDependencies": { 208 | "@types/codemirror": "^5.60.0", 209 | "@types/fb-watchman": "^2.0.1", 210 | "@types/jest": "26.0.20", 211 | "@types/line-reader": "^0.0.34", 212 | "@types/node": "14.14.33", 213 | "@types/prettier": "2.2.2", 214 | "@types/semver": "7.3.4", 215 | "@types/vscode": "^1.62.0", 216 | "esbuild": "^0.12.9", 217 | "jest": "26.6.3", 218 | "ts-jest": "26.5.3" 219 | }, 220 | "resolutions": { 221 | "graphql": "15.8.0", 222 | "graphql-config": "3.3.0", 223 | "graphql-language-service-interface": "2.8.4", 224 | "graphql-language-service-parser": "1.9.2", 225 | "graphql-language-service-server": "2.6.3", 226 | "graphql-language-service-types": "1.8.2", 227 | "graphql-language-service-utils": "2.5.3" 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/ReScriptEditorSupport.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | import * as path from "path"; 3 | import { execFileSync } from "child_process"; 4 | import fs from "fs"; 5 | import * as os from "os"; 6 | import { 7 | DocumentUri, 8 | HoverParams, 9 | ReferenceParams, 10 | RequestMessage, 11 | ResponseMessage, 12 | TypeDefinitionParams, 13 | } from "vscode-languageserver-protocol"; 14 | 15 | let bsconfigPartialPath = "bsconfig.json"; 16 | 17 | let findProjectRootOfFile = (source: DocumentUri): null | DocumentUri => { 18 | let dir = path.dirname(source); 19 | if (fs.existsSync(path.join(dir, bsconfigPartialPath))) { 20 | return dir; 21 | } else { 22 | if (dir === source) { 23 | // reached top 24 | return null; 25 | } else { 26 | return findProjectRootOfFile(dir); 27 | } 28 | } 29 | }; 30 | 31 | const makeBinaryPath = (extRootDir: string) => 32 | path.join( 33 | path.join(extRootDir, "server", "analysis_binaries"), 34 | process.platform, 35 | "rescript-editor-analysis.exe" 36 | ); 37 | 38 | export function runHoverCommand(msg: RequestMessage, extRootDir: string) { 39 | let params = msg.params as HoverParams; 40 | let filePath = fileURLToPath(params.textDocument.uri); 41 | let response = runAnalysisCommand( 42 | filePath, 43 | ["hover", filePath, params.position.line, params.position.character], 44 | msg, 45 | extRootDir 46 | ); 47 | return response; 48 | } 49 | 50 | export function runTypeDefinitionCommand( 51 | msg: RequestMessage, 52 | extRootDir: string 53 | ) { 54 | let params = msg.params as TypeDefinitionParams; 55 | let filePath = fileURLToPath(params.textDocument.uri); 56 | let response = runAnalysisCommand( 57 | filePath, 58 | [ 59 | "typeDefinition", 60 | filePath, 61 | params.position.line, 62 | params.position.character, 63 | ], 64 | msg, 65 | extRootDir 66 | ); 67 | return response; 68 | } 69 | 70 | let tempFilePrefix = "rescript_format_file_" + process.pid + "__"; 71 | let tempFileId = 0; 72 | 73 | let createFileInTempDir = (extension = "") => { 74 | let tempFileName = tempFilePrefix + tempFileId + extension; 75 | tempFileId = tempFileId + 1; 76 | return path.join(os.tmpdir(), tempFileName); 77 | }; 78 | 79 | export function runCompletionCommand( 80 | msg: RequestMessage, 81 | textContent: string, 82 | extRootDir: string 83 | ) { 84 | let params = msg.params as ReferenceParams; 85 | let filePath = fileURLToPath(params.textDocument.uri); 86 | let code = textContent; 87 | let tmpname = createFileInTempDir(); 88 | fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); 89 | 90 | let response = runAnalysisCommand( 91 | filePath, 92 | [ 93 | "completion", 94 | filePath, 95 | params.position.line, 96 | params.position.character, 97 | tmpname, 98 | ], 99 | msg, 100 | extRootDir 101 | ); 102 | fs.unlink(tmpname, () => null); 103 | return response; 104 | } 105 | 106 | export let runAnalysisCommand = ( 107 | filePath: DocumentUri, 108 | args: Array, 109 | msg: RequestMessage, 110 | extRootDir: string 111 | ) => { 112 | let result = runAnalysisAfterSanityCheck(filePath, args, extRootDir); 113 | let response: ResponseMessage = { 114 | jsonrpc: "2.0", 115 | id: msg.id, 116 | result, 117 | }; 118 | return response; 119 | }; 120 | 121 | export let runAnalysisAfterSanityCheck = ( 122 | filePath: DocumentUri, 123 | args: Array, 124 | extRootDir: string 125 | ) => { 126 | const binaryPath = makeBinaryPath(extRootDir); 127 | let projectRootPath = findProjectRootOfFile(filePath); 128 | if (projectRootPath == null) { 129 | return null; 130 | } 131 | let stdout = execFileSync(binaryPath, args, { 132 | cwd: projectRootPath, 133 | }); 134 | return JSON.parse(stdout.toString()); 135 | }; 136 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/extractToFragment.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Extract to fragment component extracts even when nested quite far 1`] = ` 4 | "fragment SomeFragment_user on FriendStatus { 5 | from 6 | to 7 | }" 8 | `; 9 | 10 | exports[`Extract to fragment component extracts in simple cases 1`] = ` 11 | "fragment SomeFragment_user on User { 12 | firstName 13 | lastName 14 | }" 15 | `; 16 | 17 | exports[`Extract to fragment component extracts in slightly more advanced examples 1`] = ` 18 | "fragment SomeFragment_user on Friend { 19 | avatarUrl 20 | friendsSince 21 | status { 22 | since 23 | from 24 | to 25 | } 26 | }" 27 | `; 28 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/findGraphQLSources.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`findGraphQLSources finds ReScript sources 1`] = ` 4 | Array [ 5 | Object { 6 | "content": " 7 | fragment SingleTodo_todoItem on TodoItem { 8 | id 9 | text 10 | completed 11 | } 12 | ", 13 | "end": Object { 14 | "character": 0, 15 | "line": 7, 16 | }, 17 | "moduleName": "TodoFragment", 18 | "start": Object { 19 | "character": 3, 20 | "line": 1, 21 | }, 22 | "type": "TAG", 23 | }, 24 | Object { 25 | "content": " 26 | mutation SingleTodoDeleteMutation( 27 | $input: DeleteTodoItemInput! 28 | $connections: [ID!]! 29 | ) @raw_response_type { 30 | deleteTodoItem(input: $input) { 31 | deletedTodoItemId @deleteEdge(connections: $connections) 32 | } 33 | } 34 | ", 35 | "end": Object { 36 | "character": 0, 37 | "line": 20, 38 | }, 39 | "moduleName": "DeleteMutation", 40 | "start": Object { 41 | "character": 3, 42 | "line": 11, 43 | }, 44 | "type": "TAG", 45 | }, 46 | Object { 47 | "content": " 48 | mutation SingleTodoUpdateMutation($input: UpdateTodoItemInput!) { 49 | updateTodoItem(input: $input) { 50 | updatedTodoItem { 51 | id 52 | text 53 | completed 54 | } 55 | } 56 | } 57 | ", 58 | "end": Object { 59 | "character": 0, 60 | "line": 34, 61 | }, 62 | "moduleName": "UpdateMutation", 63 | "start": Object { 64 | "character": 3, 65 | "line": 24, 66 | }, 67 | "type": "TAG", 68 | }, 69 | ] 70 | `; 71 | -------------------------------------------------------------------------------- /src/__tests__/contextUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from "graphql"; 2 | import { 3 | extractContextFromHover, 4 | findGraphQLRecordContext, 5 | findRecordAndModulesFromCompletion, 6 | } from "../contextUtilsNoVscode"; 7 | 8 | describe("extractContextFromHover", () => { 9 | it("finds the context of a hover string from the ReScript VSCode extension", () => { 10 | expect( 11 | extractContextFromHover( 12 | "x", 13 | "```rescript\nReasonReactExamples.SingleTicket_ticket_graphql.Types.fragment\n```\n\n```rescript\ntype fragment = {\n assignee: option<\n [\n | #UnselectedUnionMember(string)\n | #User(fragment_assignee_User)\n | #WorkingGroup(fragment_assignee_WorkingGroup)\n ],\n >,\n id: string,\n subject: string,\n lastUpdated: option,\n trackingId: string,\n fragmentRefs: RescriptRelay.fragmentRefs<\n [#TicketStatusBadge_ticket],\n >,\n}\n```" 14 | ) 15 | ).toEqual({ 16 | graphqlName: "SingleTicket_ticket", 17 | graphqlType: "fragment", 18 | recordName: "fragment", 19 | propName: "x", 20 | type: "GraphQLValue", 21 | }); 22 | }); 23 | 24 | it("finds the context of a hover string from the ReScript VSCode extension, for queries", () => { 25 | expect( 26 | extractContextFromHover( 27 | "x", 28 | "```rescript\nReasonReactExamples.SingleTicketQuery_graphql.Types.response\n```\n\n```rescript\ntype fragment = {\n assignee: option<\n [\n | #UnselectedUnionMember(string)\n | #User(fragment_assignee_User)\n | #WorkingGroup(fragment_assignee_WorkingGroup)\n ],\n >,\n id: string,\n subject: string,\n lastUpdated: option,\n trackingId: string,\n fragmentRefs: RescriptRelay.fragmentRefs<\n [#TicketStatusBadge_ticket],\n >,\n}\n```" 29 | ) 30 | ).toEqual({ 31 | graphqlName: "SingleTicketQuery", 32 | graphqlType: "query", 33 | recordName: "response", 34 | propName: "x", 35 | type: "GraphQLValue", 36 | }); 37 | }); 38 | 39 | it("finds the context of a hover string from the ReScript VSCode extension, alternate formatting", () => { 40 | expect( 41 | extractContextFromHover( 42 | "x", 43 | '```rescript"TodoList_query_graphql-ReasonReactExamples".Types.fragment_todosConnection_edges_node\n type fragment_todosConnection_edges_node = {\n id: string,\n fragmentRefs: RescriptRelay.fragmentRefs<\n [#SingleTodo_todoItem],\n >,\n }```' 44 | ) 45 | ).toEqual({ 46 | graphqlName: "TodoList_query", 47 | graphqlType: "fragment", 48 | recordName: "fragment_todosConnection_edges_node", 49 | propName: "x", 50 | type: "GraphQLValue", 51 | }); 52 | }); 53 | 54 | it("finds the context of a hover string with a more complex path", () => { 55 | expect( 56 | extractContextFromHover( 57 | "x", 58 | "```rescript\nReasonReactExamples.SingleTicket_ticket_graphql.Types.fragment_user_friends_Friend_node\n```\n\n```rescript\ntype fragment = {\n assignee: option<\n [\n | #UnselectedUnionMember(string)\n | #User(fragment_assignee_User)\n | #WorkingGroup(fragment_assignee_WorkingGroup)\n ],\n >,\n id: string,\n subject: string,\n lastUpdated: option,\n trackingId: string,\n fragmentRefs: RescriptRelay.fragmentRefs<\n [#TicketStatusBadge_ticket | #TicketHeader_ticket],\n >,\n}\n```" 59 | ) 60 | ).toEqual({ 61 | propName: "x", 62 | graphqlName: "SingleTicket_ticket", 63 | graphqlType: "fragment", 64 | recordName: "fragment_user_friends_Friend_node", 65 | type: "GraphQLValue", 66 | }); 67 | }); 68 | 69 | it("finds the context of wrapped option strings", () => { 70 | expect( 71 | extractContextFromHover( 72 | "x", 73 | `array< 74 | option< 75 | ReasonReactExamples.SingleTicketWorkingGroup_workingGroup_graphql.Types.fragment_membersConnection_edges, 76 | >, 77 | >` 78 | ) 79 | ).toEqual({ 80 | propName: "x", 81 | graphqlName: "SingleTicketWorkingGroup_workingGroup", 82 | graphqlType: "fragment", 83 | recordName: "fragment_membersConnection_edges", 84 | type: "GraphQLValue", 85 | }); 86 | }); 87 | 88 | it("does not match deceptively similar type names", () => { 89 | expect( 90 | extractContextFromHover( 91 | "x", 92 | "```rescript\nReasonReactExamples.SingleTicket_ticket.Types.fragment_user_friends_Friend_node\n```\n\n```rescript\ntype fragment = {\n assignee: option<\n [\n | #UnselectedUnionMember(string)\n | #User(fragment_assignee_User)\n | #WorkingGroup(fragment_assignee_WorkingGroup)\n ],\n >,\n id: string,\n subject: string,\n lastUpdated: option,\n trackingId: string,\n fragmentRefs: RescriptRelay.fragmentRefs<\n [#TicketStatusBadge_ticket],\n >,\n}\n```" 93 | ) 94 | ).toEqual(null); 95 | }); 96 | 97 | it("ignores functions", () => { 98 | expect( 99 | extractContextFromHover( 100 | "x", 101 | `\"RecentTickets_query_graphql-ReasonReactExamples".Types.fragment_ticketsConnection => array< 102 | \"RecentTickets_query_graphql-ReasonReactExamples".Types.fragment_ticketsConnection_edges_node, 103 | >` 104 | ) 105 | ).toEqual(null); 106 | }); 107 | 108 | it("uses correct heuristic for unions", () => { 109 | expect( 110 | extractContextFromHover( 111 | "x", 112 | "```rescript\n[\n | #WorkingGroup(ReasonReactExamples.SingleTicket_ticket_graphql.Types.fragment_assignee_WorkingGroup)\n | #User(ReasonReactExamples.SingleTicket_ticket_graphql.Types.fragment_assignee_User)\n | #UnselectedUnionMember(string)\n ]\n```" 113 | ) 114 | ).toEqual({ 115 | graphqlName: "SingleTicket_ticket", 116 | graphqlType: "fragment", 117 | propName: "x", 118 | recordName: "fragment_assignee", 119 | type: "GraphQLValue", 120 | }); 121 | }); 122 | }); 123 | 124 | const mockSchema = buildSchema(` 125 | """ 126 | A user. 127 | """ 128 | type User { 129 | id: ID! 130 | """ 131 | The age of the user. 132 | """ 133 | age: Int! 134 | bestFriend: User 135 | } 136 | 137 | type Query { 138 | me: User 139 | } 140 | `); 141 | 142 | describe("findGraphQLRecordContext", () => { 143 | it("finds the relevant context", () => { 144 | const ctx = findGraphQLRecordContext( 145 | `fragment Test_user on User { 146 | id 147 | bestFriend { 148 | id 149 | age 150 | } 151 | }`, 152 | "fragment_bestFriend", 153 | mockSchema, 154 | "fragment" 155 | ); 156 | 157 | expect(ctx?.type.name).toBe("User"); 158 | expect(ctx?.startLoc?.line).toBe(3); 159 | expect(ctx?.endLoc?.line).toBe(6); 160 | 161 | expect(ctx?.startLoc?.column).toBe(5); 162 | expect(ctx?.endLoc?.column).toBe(6); 163 | 164 | expect(ctx?.description).toBe("A user."); 165 | }); 166 | 167 | it("finds the relevant context of a single field", () => { 168 | const ctx = findGraphQLRecordContext( 169 | `fragment Test_user on User { 170 | id 171 | bestFriend { 172 | id 173 | age 174 | } 175 | }`, 176 | "fragment_bestFriend_age", 177 | mockSchema, 178 | "fragment" 179 | ); 180 | 181 | expect(ctx?.type.name).toBe("Int"); 182 | expect(ctx?.fieldTypeAsString).toBe("Int!"); 183 | expect(ctx?.startLoc?.line).toBe(5); 184 | expect(ctx?.endLoc?.line).toBe(5); 185 | 186 | expect(ctx?.startLoc?.column).toBe(7); 187 | expect(ctx?.endLoc?.column).toBe(10); 188 | 189 | expect(ctx?.description).toBe("The age of the user."); 190 | }); 191 | 192 | it("excludes built in scalar descriptions", () => { 193 | const ctx = findGraphQLRecordContext( 194 | `fragment Test_user on User { 195 | id 196 | bestFriend { 197 | id 198 | age 199 | } 200 | }`, 201 | "fragment_bestFriend_id", 202 | mockSchema, 203 | "fragment" 204 | ); 205 | 206 | expect(ctx?.type.name).toBe("ID"); 207 | expect(ctx?.fieldTypeAsString).toBe("ID!"); 208 | 209 | expect(ctx?.description).toBe(null); 210 | }); 211 | }); 212 | 213 | describe("findRecordAndModulesFromCompletion", () => { 214 | it("handles completion items for fragments", () => { 215 | expect( 216 | findRecordAndModulesFromCompletion({ 217 | label: "user", 218 | detail: 219 | "ReasonReactExamples.SingleTicket_ticket_graphql.Types.fragment_assignee_User", 220 | }) 221 | ).toEqual({ 222 | label: "user", 223 | module: "SingleTicket_ticket_graphql", 224 | graphqlName: "SingleTicket_ticket", 225 | graphqlType: "fragment", 226 | recordName: "fragment_assignee_User", 227 | }); 228 | }); 229 | 230 | it("handles completion items for queries", () => { 231 | expect( 232 | findRecordAndModulesFromCompletion({ 233 | label: "user", 234 | detail: 235 | "ReasonReactExamples.SingleTicketQuery_graphql.Types.response_assignee_User", 236 | }) 237 | ).toEqual({ 238 | label: "user", 239 | module: "SingleTicketQuery_graphql", 240 | graphqlName: "SingleTicketQuery", 241 | graphqlType: "query", 242 | recordName: "response_assignee_User", 243 | }); 244 | }); 245 | 246 | it("handles completion items for mutations", () => { 247 | expect( 248 | findRecordAndModulesFromCompletion({ 249 | label: "user", 250 | detail: 251 | "ReasonReactExamples.SingleTicketMutation_graphql.Types.response_assignee_User", 252 | }) 253 | ).toEqual({ 254 | label: "user", 255 | module: "SingleTicketMutation_graphql", 256 | graphqlName: "SingleTicketMutation", 257 | graphqlType: "mutation", 258 | recordName: "response_assignee_User", 259 | }); 260 | }); 261 | 262 | it("handles completion items for subscriptions", () => { 263 | expect( 264 | findRecordAndModulesFromCompletion({ 265 | label: "user", 266 | detail: 267 | "ReasonReactExamples.SingleTicketSubscription_graphql.Types.response_assignee_User", 268 | }) 269 | ).toEqual({ 270 | label: "user", 271 | module: "SingleTicketSubscription_graphql", 272 | graphqlName: "SingleTicketSubscription", 273 | graphqlType: "subscription", 274 | recordName: "response_assignee_User", 275 | }); 276 | }); 277 | 278 | it("handles completion items with differen formatting", () => { 279 | expect( 280 | findRecordAndModulesFromCompletion({ 281 | label: "todoItem", 282 | detail: `\"TodoList_query_graphql-ReasonReactExamples".Types.fragment_todosConnection_edges_node`, 283 | }) 284 | ).toEqual({ 285 | label: "todoItem", 286 | module: "TodoList_query_graphql", 287 | graphqlName: "TodoList_query", 288 | graphqlType: "fragment", 289 | recordName: "fragment_todosConnection_edges_node", 290 | }); 291 | }); 292 | 293 | it("ignores irrelevant stuff", () => { 294 | expect( 295 | findRecordAndModulesFromCompletion({ 296 | label: "user", 297 | detail: "string", 298 | }) 299 | ).toEqual(null); 300 | }); 301 | }); 302 | -------------------------------------------------------------------------------- /src/__tests__/extensionUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | extractFragmentRefs, 3 | validateRescriptVariableName, 4 | } from "../extensionUtilsNoVscode"; 5 | 6 | describe("validateRescriptVariableName", () => { 7 | it("should validate ReScript variable names", () => { 8 | expect(validateRescriptVariableName("someProp")).toBe(true); 9 | expect(validateRescriptVariableName("SomeProp")).toBe(false); 10 | expect(validateRescriptVariableName("123_lol")).toBe(false); 11 | expect(validateRescriptVariableName("lol_123")).toBe(true); 12 | expect(validateRescriptVariableName("todoL")).toBe(true); 13 | expect(validateRescriptVariableName("TodoL")).toBe(false); 14 | expect(validateRescriptVariableName("todo ")).toBe(false); 15 | }); 16 | }); 17 | 18 | describe("extractFragmentRefs", () => { 19 | it("should extract fragmentRefs", () => { 20 | expect( 21 | extractFragmentRefs(`fragmentRefs: RescriptRelay.fragmentRefs< 22 | [#TodoListT_item | #TodoListTest_item], 23 | > 24 | 25 | type fragment = { 26 | todosConnection: fragment_todosConnection, 27 | fragmentRefs: RescriptRelay.fragmentRefs< 28 | [#TodoListT_item | #TodoListTest_item], 29 | >, 30 | }`) 31 | ).toEqual(["TodoListT_item", "TodoListTest_item"]); 32 | 33 | expect( 34 | extractFragmentRefs(`fragmentRefs: RescriptRelay.fragmentRefs<[#Avatar_user]> 35 | 36 | type fragment_assignee_User = { 37 | fragmentRefs: RescriptRelay.fragmentRefs<[#Avatar_user]>, 38 | }`) 39 | ).toEqual(["Avatar_user"]); 40 | 41 | expect( 42 | extractFragmentRefs(`fragmentRefs: RescriptRelay.fragmentRefs< 43 | [ 44 | | #TopBarImportantSectionDataUpdatedNotifier_organization 45 | | #TopBarImportantSectionUrgentApiConnectionIssues_organization 46 | ], 47 | > 48 | 49 | type fragment_organizationBySlug = { 50 | fragmentRefs: RescriptRelay.fragmentRefs< 51 | [ 52 | | #TopBarImportantSectionDataUpdatedNotifier_organization 53 | | #TopBarImportantSectionUrgentApiConnectionIssues_organization 54 | ], 55 | >, 56 | }`) 57 | ).toEqual([ 58 | "TopBarImportantSectionDataUpdatedNotifier_organization", 59 | "TopBarImportantSectionUrgentApiConnectionIssues_organization", 60 | ]); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/__tests__/extractToFragment.test.ts: -------------------------------------------------------------------------------- 1 | import { parse, Source, print, buildSchema, SelectionNode } from "graphql"; 2 | import { extractToFragment } from "../createNewFragmentComponentsUtils"; 3 | import { getSelectedGraphQLOperation } from "../findGraphQLSources"; 4 | 5 | const makeMockNormalizedSelection = ( 6 | startLine: number, 7 | endLine: number 8 | ): [any, any] => [{ line: startLine }, { line: endLine }]; 9 | 10 | const printExtractedFragment = ( 11 | parentTypeName: string, 12 | selections: SelectionNode[] 13 | ) => 14 | print({ 15 | kind: "FragmentDefinition", 16 | typeCondition: { 17 | kind: "NamedType", 18 | name: { kind: "Name", value: parentTypeName }, 19 | }, 20 | name: { kind: "Name", value: "SomeFragment_user" }, 21 | selectionSet: { 22 | kind: "SelectionSet", 23 | selections: selections, 24 | }, 25 | }); 26 | 27 | const mockSchema = ` 28 | type User { 29 | id: ID! 30 | firstName: String! 31 | lastName: String! 32 | bestFriend: Friend! 33 | } 34 | 35 | type Friend { 36 | id: ID! 37 | avatarUrl: String! 38 | friendsSince: Int! 39 | status: FriendStatus 40 | } 41 | 42 | type FriendStatus { 43 | since: Int! 44 | from: String 45 | to: String 46 | } 47 | `; 48 | 49 | const schema = buildSchema(mockSchema); 50 | 51 | const targetSource = `// Other stuff 52 | 53 | module Fragment = %relay(\` 54 | fragment SomeFragment_user on User { 55 | id 56 | firstName 57 | lastName 58 | bestFriend { 59 | id 60 | avatarUrl 61 | friendsSince 62 | status { 63 | since 64 | from 65 | to 66 | } 67 | } 68 | } 69 | \` 70 | ) 71 | 72 | // Some other source here 73 | `; 74 | 75 | const selectedOp = getSelectedGraphQLOperation(targetSource, { 76 | line: 6, 77 | character: 2, 78 | } as any); 79 | 80 | if (!selectedOp) { 81 | throw new Error("Select an op please.."); 82 | } 83 | 84 | const parsedOp = parse(selectedOp.content); 85 | const source = new Source(selectedOp.content); 86 | 87 | describe("Extract to fragment component", () => { 88 | it("extracts in simple cases", () => { 89 | const extractedFragment = extractToFragment({ 90 | schema, 91 | normalizedSelection: makeMockNormalizedSelection(4, 6), 92 | parsedOp, 93 | source, 94 | }); 95 | 96 | if (!extractedFragment) { 97 | throw new Error("Could not extract fragment."); 98 | } 99 | 100 | expect( 101 | printExtractedFragment( 102 | extractedFragment.parentTypeName, 103 | extractedFragment.selections 104 | ) 105 | ).toMatchSnapshot(); 106 | }); 107 | 108 | it("extracts in slightly more advanced examples", () => { 109 | const extractedFragment = extractToFragment({ 110 | schema, 111 | normalizedSelection: makeMockNormalizedSelection(8, 15), 112 | parsedOp, 113 | source, 114 | }); 115 | 116 | if (!extractedFragment) { 117 | throw new Error("Could not extract fragment."); 118 | } 119 | 120 | expect( 121 | printExtractedFragment( 122 | extractedFragment.parentTypeName, 123 | extractedFragment.selections 124 | ) 125 | ).toMatchSnapshot(); 126 | }); 127 | 128 | it("extracts even when nested quite far", () => { 129 | const extractedFragment = extractToFragment({ 130 | schema, 131 | normalizedSelection: makeMockNormalizedSelection(12, 13), 132 | parsedOp, 133 | source, 134 | }); 135 | 136 | if (!extractedFragment) { 137 | throw new Error("Could not extract fragment."); 138 | } 139 | 140 | expect( 141 | printExtractedFragment( 142 | extractedFragment.parentTypeName, 143 | extractedFragment.selections 144 | ) 145 | ).toMatchSnapshot(); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/__tests__/findGraphQLSources.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { extractGraphQLSourceFromReScript } from "../findGraphQLSources"; 4 | 5 | const fixture = fs.readFileSync( 6 | path.resolve( 7 | path.join(__dirname, "../", "testfixture", "graphqlSources.res") 8 | ), 9 | "utf8" 10 | ); 11 | 12 | describe("findGraphQLSources", () => { 13 | it("finds ReScript sources", () => { 14 | expect(extractGraphQLSourceFromReScript(fixture)).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/__tests__/graphqlUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema, parse, print } from "graphql"; 2 | import { addFieldAtPosition } from "../contextUtilsNoVscode"; 3 | 4 | const mockSchema = buildSchema(` 5 | type Dog { 6 | id: ID! 7 | name: String! 8 | } 9 | 10 | type Cat { 11 | id: ID! 12 | name: String! 13 | } 14 | 15 | union Pet = Dog | Cat 16 | 17 | """ 18 | A user. 19 | """ 20 | type User { 21 | id: ID! 22 | """ 23 | The age of the user. 24 | """ 25 | age: Int! 26 | bestFriend: User 27 | pets: [Pet!] 28 | } 29 | 30 | type Query { 31 | me: User 32 | } 33 | `); 34 | 35 | describe("addFieldAtPosition", () => { 36 | it("adds simple fields", () => { 37 | expect( 38 | print( 39 | addFieldAtPosition( 40 | parse(`fragment SomeFragment on User { 41 | id 42 | }`), 43 | "fragment", 44 | // @ts-ignore 45 | mockSchema.getType("User"), 46 | "age", 47 | "fragment" 48 | ) 49 | ).trim() 50 | ).toEqual( 51 | `fragment SomeFragment on User { 52 | id 53 | age 54 | }` 55 | ); 56 | }); 57 | it("adds simple fields in nested position", () => { 58 | expect( 59 | print( 60 | addFieldAtPosition( 61 | parse(`fragment SomeFragment on User { 62 | id 63 | bestFriend { 64 | id 65 | } 66 | }`), 67 | "fragment_bestFriend", 68 | // @ts-ignore 69 | mockSchema.getType("User"), 70 | "age", 71 | "fragment" 72 | ) 73 | ).trim() 74 | ).toEqual( 75 | `fragment SomeFragment on User { 76 | id 77 | bestFriend { 78 | id 79 | age 80 | } 81 | }` 82 | ); 83 | }); 84 | it("adds complex fields", () => { 85 | expect( 86 | print( 87 | addFieldAtPosition( 88 | parse(`fragment SomeFragment on User { 89 | id 90 | }`), 91 | "fragment", 92 | // @ts-ignore 93 | mockSchema.getType("User"), 94 | "bestFriend", 95 | "fragment" 96 | ) 97 | ).trim() 98 | ).toEqual( 99 | `fragment SomeFragment on User { 100 | id 101 | bestFriend { 102 | id 103 | } 104 | }` 105 | ); 106 | }); 107 | it("adds complex fields in union", () => { 108 | expect( 109 | print( 110 | addFieldAtPosition( 111 | parse(`fragment SomeFragment on User { 112 | id 113 | pets { 114 | ... on Dog { 115 | id 116 | } 117 | } 118 | }`), 119 | "fragment_pets_Dog", 120 | // @ts-ignore 121 | mockSchema.getType("Dog"), 122 | "name", 123 | "fragment" 124 | ) 125 | ).trim() 126 | ).toEqual( 127 | `fragment SomeFragment on User { 128 | id 129 | pets { 130 | ... on Dog { 131 | id 132 | name 133 | } 134 | } 135 | }` 136 | ); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/__tests__/hasHighEnoughReScriptRelayVersion.ts: -------------------------------------------------------------------------------- 1 | import { hasHighEnoughReScriptRelayVersion } from "../utilsNoVsCode"; 2 | 3 | it("identifies high enough versions", () => { 4 | expect(hasHighEnoughReScriptRelayVersion("0.13.0")).toBe(true); 5 | expect(hasHighEnoughReScriptRelayVersion("^0.13.0")).toBe(true); 6 | expect(hasHighEnoughReScriptRelayVersion("~0.13.0")).toBe(true); 7 | expect(hasHighEnoughReScriptRelayVersion("0.14.0")).toBe(true); 8 | expect(hasHighEnoughReScriptRelayVersion("^0.14.0")).toBe(true); 9 | expect(hasHighEnoughReScriptRelayVersion("~0.14.0")).toBe(true); 10 | expect(hasHighEnoughReScriptRelayVersion("1.0.0")).toBe(true); 11 | expect(hasHighEnoughReScriptRelayVersion("^1.0.0")).toBe(true); 12 | expect(hasHighEnoughReScriptRelayVersion("2.0.0")).toBe(true); 13 | expect(hasHighEnoughReScriptRelayVersion("^2.0.0")).toBe(true); 14 | }); 15 | -------------------------------------------------------------------------------- /src/addGraphQLComponent.ts: -------------------------------------------------------------------------------- 1 | import { InsertGraphQLComponentType } from "./extensionTypes"; 2 | import { 3 | capitalize, 4 | FragmentCreationSource, 5 | fragmentCreationWizard, 6 | uncapitalize, 7 | } from "./extensionUtils"; 8 | import { loadFullSchema } from "./loadSchema"; 9 | 10 | import { TextEditorEdit, window, Selection } from "vscode"; 11 | 12 | import { 13 | GraphQLSchema, 14 | isInterfaceType, 15 | isObjectType, 16 | VariableDefinitionNode, 17 | ArgumentNode, 18 | } from "graphql"; 19 | import { 20 | makeFragment, 21 | makeOperation, 22 | makeVariableDefinitionNode, 23 | } from "./graphqlUtils"; 24 | import { pascalCase } from "pascal-case"; 25 | import { makeFieldSelection } from "./graphqlUtilsNoVscode"; 26 | 27 | async function getValidModuleName( 28 | docText: string, 29 | name: string 30 | ): Promise { 31 | const newName = docText.includes(`module ${name} =`) 32 | ? await window.showInputBox({ 33 | prompt: "Enter module name ('" + name + "' already exists in document)", 34 | validateInput: (v: string) => 35 | v !== name ? null : "Name cannot be '" + name + "'.", 36 | value: name, 37 | }) 38 | : null; 39 | 40 | return newName || name; 41 | } 42 | 43 | interface QuickPickFromSchemaResult { 44 | schemaPromise: Promise; 45 | result: Thenable; 46 | } 47 | 48 | export function quickPickFromSchema( 49 | placeHolder: string | undefined, 50 | getItems: (schema: GraphQLSchema) => string[] 51 | ): QuickPickFromSchemaResult { 52 | const schemaPromise = loadFullSchema(); 53 | 54 | return { 55 | schemaPromise, 56 | result: window.showQuickPick( 57 | schemaPromise.then((maybeSchema: GraphQLSchema | undefined) => { 58 | if (maybeSchema) { 59 | return getItems(maybeSchema); 60 | } 61 | 62 | return []; 63 | }), 64 | { 65 | placeHolder, 66 | } 67 | ), 68 | }; 69 | } 70 | 71 | const sanitizeModuleName = (moduleName: string) => moduleName.replace(/_/g, ""); 72 | 73 | export async function addGraphQLComponent(type: InsertGraphQLComponentType) { 74 | const textEditor = window.activeTextEditor; 75 | 76 | if (!textEditor) { 77 | window.showErrorMessage("Missing active text editor."); 78 | return; 79 | } 80 | 81 | const docText = textEditor.document.getText(); 82 | 83 | let insert = ""; 84 | 85 | // TODO: Fix this, this is insane 86 | const moduleName = sanitizeModuleName( 87 | capitalize( 88 | (textEditor.document.fileName.split(/\\|\//).pop() || "") 89 | .split(".") 90 | .shift() || "" 91 | ) 92 | ); 93 | 94 | switch (type) { 95 | case "Fragment": { 96 | const newFragmentProps = await fragmentCreationWizard({ 97 | selectedVariableName: null!, 98 | source: FragmentCreationSource.CodegenInFile, 99 | type: null!, 100 | uri: textEditor.document.uri, 101 | }); 102 | 103 | if (newFragmentProps == null) { 104 | return; 105 | } 106 | 107 | const { fragmentName, variableName, type } = newFragmentProps; 108 | 109 | const targetModuleName = `${pascalCase(variableName)}Fragment`; 110 | const propName = uncapitalize(variableName); 111 | 112 | insert += `module ${targetModuleName} = %relay(\`\n ${await makeFragment( 113 | fragmentName, 114 | type.name 115 | )}\n\`\n)`; 116 | 117 | const shouldInsertComponentBoilerplate = 118 | (await window.showQuickPick(["Yes", "No"], { 119 | placeHolder: "Do you also want to add boilerplate for a component?", 120 | })) === "Yes"; 121 | 122 | if (shouldInsertComponentBoilerplate) { 123 | insert += `\n\n 124 | @react.component 125 | let make = (~${propName}) => { 126 | let ${propName} = ${targetModuleName}.use(${propName}) 127 | 128 | React.null 129 | }`; 130 | } 131 | break; 132 | } 133 | case "Query": { 134 | const { schemaPromise, result } = quickPickFromSchema( 135 | "Select root field", 136 | (s) => { 137 | const queryObj = s.getQueryType(); 138 | if (queryObj) { 139 | return Object.keys(queryObj.getFields()).filter( 140 | (k) => !k.startsWith("__") 141 | ); 142 | } 143 | 144 | return []; 145 | } 146 | ); 147 | 148 | const query = await result; 149 | 150 | if (!query) { 151 | return; 152 | } 153 | 154 | const queryField = await schemaPromise.then((schema) => { 155 | if (schema) { 156 | const queryObj = schema.getQueryType(); 157 | if (queryObj) { 158 | return queryObj.getFields()[query] || null; 159 | } 160 | } 161 | 162 | return null; 163 | }); 164 | 165 | if (!queryField) { 166 | return; 167 | } 168 | 169 | let onType: string | undefined; 170 | 171 | if (queryField.name === "node" && isInterfaceType(queryField.type)) { 172 | const schema = await schemaPromise; 173 | if (schema) { 174 | const possibleTypes = schema.getPossibleTypes(queryField.type); 175 | onType = await window.showQuickPick( 176 | possibleTypes.map((pt) => pt.name), 177 | { 178 | placeHolder: 179 | "What type do you want to use on the node interface?", 180 | } 181 | ); 182 | } 183 | } 184 | 185 | const rModuleName = await getValidModuleName(docText, `Query`); 186 | 187 | insert += `module ${rModuleName} = %relay(\`\n ${await makeOperation({ 188 | operationType: "query", 189 | operationName: `${moduleName}${rModuleName}${ 190 | rModuleName.endsWith("Query") ? "" : "Query" 191 | }`, 192 | rootField: queryField, 193 | onType, 194 | })}\n\`)`; 195 | 196 | const shouldInsertComponentBoilerplate = 197 | (await window.showQuickPick(["Yes", "No"], { 198 | placeHolder: "Do you also want to add boilerplate for a component?", 199 | })) === "Yes"; 200 | 201 | if (shouldInsertComponentBoilerplate) { 202 | const typeOfQuery = await window.showQuickPick(["Preloaded", "Lazy"], { 203 | placeHolder: "What type of query are you making?", 204 | }); 205 | 206 | insert += `\n\n 207 | @react.component 208 | let make = (${typeOfQuery === "Preloaded" ? "~queryRef" : ""}) => { 209 | ${ 210 | typeOfQuery === "Preloaded" 211 | ? `let data = ${rModuleName}.usePreloaded(~queryRef, ())` 212 | : `let data = ${rModuleName}.use(~variables=(), ())` 213 | } 214 | 215 | React.null 216 | }`; 217 | } 218 | break; 219 | } 220 | case "Mutation": { 221 | const { schemaPromise, result } = quickPickFromSchema( 222 | "Select mutation", 223 | (s) => { 224 | const mutationObj = s.getMutationType(); 225 | if (mutationObj) { 226 | return Object.keys(mutationObj.getFields()).filter( 227 | (k) => !k.startsWith("__") 228 | ); 229 | } 230 | 231 | return []; 232 | } 233 | ); 234 | 235 | const mutation = await result; 236 | 237 | if (!mutation) { 238 | return; 239 | } 240 | 241 | const mutationField = await schemaPromise.then((schema) => { 242 | if (schema) { 243 | const mutationObj = schema.getMutationType(); 244 | if (mutationObj) { 245 | return mutationObj.getFields()[mutation] || null; 246 | } 247 | } 248 | 249 | return null; 250 | }); 251 | 252 | if (!mutationField) { 253 | return; 254 | } 255 | 256 | let mutationSubFieldConfig: 257 | | { 258 | fieldName: string; 259 | args: { 260 | name: string; 261 | type: string; 262 | }[]; 263 | } 264 | | undefined; 265 | 266 | if (isObjectType(mutationField.type)) { 267 | const fields = mutationField.type.getFields(); 268 | const fieldNames = Object.keys(fields); 269 | const field = 270 | fieldNames.length === 1 271 | ? fieldNames[0] 272 | : await window.showQuickPick(fieldNames, { 273 | placeHolder: 274 | "Select the field you want to target on your mutation", 275 | }); 276 | 277 | if (field) { 278 | const theField = fields[field]; 279 | const args = mutationField.args.filter((arg) => 280 | arg.type.toString().endsWith("!") 281 | ); 282 | 283 | mutationSubFieldConfig = { 284 | fieldName: theField.name, 285 | args: args.map((arg) => ({ 286 | name: arg.name, 287 | type: arg.type.toString(), 288 | })), 289 | }; 290 | } 291 | } 292 | 293 | const rModuleName = await getValidModuleName( 294 | docText, 295 | `${capitalize(mutation)}Mutation` 296 | ); 297 | 298 | insert += `module ${rModuleName} = %relay(\`\n ${await makeOperation({ 299 | operationType: "mutation", 300 | operationName: `${sanitizeModuleName(moduleName)}_${capitalize( 301 | mutation 302 | )}Mutation`, 303 | rootField: mutationField, 304 | skipAddingFieldSelections: !!mutationSubFieldConfig, 305 | creator: mutationSubFieldConfig 306 | ? (node) => { 307 | if (!mutationSubFieldConfig) { 308 | return node; 309 | } 310 | 311 | return { 312 | ...node, 313 | variableDefinitions: mutationSubFieldConfig.args.reduce( 314 | (acc: VariableDefinitionNode[], curr) => { 315 | const varNode = makeVariableDefinitionNode( 316 | curr.name, 317 | curr.type 318 | ); 319 | 320 | if (varNode) { 321 | acc.push(varNode); 322 | } 323 | return acc; 324 | }, 325 | [] 326 | ), 327 | selectionSet: { 328 | kind: "SelectionSet", 329 | selections: [ 330 | { 331 | kind: "Field", 332 | arguments: mutationSubFieldConfig.args.map( 333 | (arg): ArgumentNode => ({ 334 | kind: "Argument", 335 | value: { 336 | kind: "Variable", 337 | name: { 338 | kind: "Name", 339 | value: arg.name, 340 | }, 341 | }, 342 | name: { 343 | kind: "Name", 344 | value: arg.name, 345 | }, 346 | }) 347 | ), 348 | name: { 349 | kind: "Name", 350 | value: mutationField.name, 351 | }, 352 | selectionSet: { 353 | kind: "SelectionSet", 354 | selections: [ 355 | makeFieldSelection(mutationSubFieldConfig.fieldName), 356 | ], 357 | }, 358 | }, 359 | ], 360 | }, 361 | }; 362 | } 363 | : undefined, 364 | })}\n\`)`; 365 | 366 | const shouldInsertComponentBoilerplate = 367 | (await window.showQuickPick(["Yes", "No"], { 368 | placeHolder: "Do you also want to add boilerplate for a component?", 369 | })) === "Yes"; 370 | 371 | if (shouldInsertComponentBoilerplate) { 372 | insert += `\n\n 373 | @react.component 374 | let make = () => { 375 | let (mutate, isMutating) = ${rModuleName}.use() 376 | 377 | React.null 378 | }`; 379 | } 380 | break; 381 | } 382 | 383 | case "Subscription": { 384 | const { schemaPromise, result } = quickPickFromSchema( 385 | "Select subscription", 386 | (s) => { 387 | const subscriptionObj = s.getSubscriptionType(); 388 | if (subscriptionObj) { 389 | return Object.keys(subscriptionObj.getFields()).filter( 390 | (k) => !k.startsWith("__") 391 | ); 392 | } 393 | 394 | return []; 395 | } 396 | ); 397 | 398 | const subscription = await result; 399 | 400 | if (!subscription) { 401 | return; 402 | } 403 | 404 | const subscriptionField = await schemaPromise.then((schema) => { 405 | if (schema) { 406 | const subscriptionObj = schema.getSubscriptionType(); 407 | if (subscriptionObj) { 408 | return subscriptionObj.getFields()[subscription] || null; 409 | } 410 | } 411 | 412 | return null; 413 | }); 414 | 415 | if (!subscriptionField) { 416 | return; 417 | } 418 | 419 | const rModuleName = await getValidModuleName(docText, `Subscription`); 420 | 421 | insert += `module ${rModuleName} = %relay(\`\n ${await makeOperation({ 422 | operationType: "subscription", 423 | operationName: `${sanitizeModuleName(moduleName)}_${capitalize( 424 | subscription 425 | )}Subscription`, 426 | rootField: subscriptionField, 427 | })}\n\`)`; 428 | 429 | const shouldInsertComponentBoilerplate = 430 | (await window.showQuickPick(["Yes", "No"], { 431 | placeHolder: "Do you also want to add boilerplate for a component?", 432 | })) === "Yes"; 433 | 434 | if (shouldInsertComponentBoilerplate) { 435 | insert += `\n\n 436 | @react.component 437 | let make = () => { 438 | let environment = RescriptRelay.useEnvironmentFromContext() 439 | 440 | React.useEffect0(() => { 441 | let subscription = ${rModuleName}.subscribe( 442 | ~environment, 443 | ~variables=(), 444 | (), 445 | ) 446 | 447 | Some(() => RescriptRelay.Disposable.dispose(subscription)) 448 | }) 449 | 450 | React.null 451 | }`; 452 | } 453 | break; 454 | } 455 | } 456 | 457 | await textEditor.edit((editBuilder: TextEditorEdit) => { 458 | const textDocument = textEditor.document; 459 | 460 | if (!textDocument) { 461 | return; 462 | } 463 | 464 | editBuilder.insert(textEditor.selection.active, insert); 465 | }); 466 | 467 | const currentPos = textEditor.selection.active; 468 | const newPos = currentPos.with(currentPos.line - 3); 469 | 470 | textEditor.selection = new Selection(newPos, newPos); 471 | 472 | const textDocument = textEditor.document; 473 | 474 | if (!textDocument) { 475 | return; 476 | } 477 | 478 | await textDocument.save(); 479 | 480 | let edited: boolean | undefined = false; 481 | 482 | if (edited) { 483 | await textDocument.save(); 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /src/comby.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from "child_process"; 2 | 3 | export const matchWithComby = ( 4 | command: string, 5 | content: string 6 | ): Promise => { 7 | const res = spawnSync( 8 | "comby", 9 | [ 10 | `'${command}'`, 11 | "''", 12 | "-stdin", 13 | "-match-only", 14 | "-json-lines", 15 | "-match-newline-at-toplevel", 16 | "-matcher", 17 | ".re", 18 | ], 19 | { 20 | shell: true, 21 | stdio: "pipe", 22 | input: content, 23 | encoding: "utf-8", 24 | } 25 | ); 26 | 27 | return JSON.parse(res.output.filter(Boolean).join("")); 28 | }; 29 | -------------------------------------------------------------------------------- /src/configUtils.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLExtensionDeclaration } from "graphql-config"; 2 | import { directiveNodes } from "./relayDirectives"; 3 | 4 | export const RelayDirectivesExtension: GraphQLExtensionDeclaration = (api) => { 5 | api.loaders.schema.use((document) => ({ 6 | ...document, 7 | definitions: [...document.definitions, ...directiveNodes], 8 | })); 9 | 10 | return { 11 | name: "VScodeReScript", 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/contextUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | runCompletionCommand, 3 | runHoverCommand, 4 | runTypeDefinitionCommand, 5 | } from "./ReScriptEditorSupport"; 6 | import LRU from "lru-cache"; 7 | import { 8 | extensions, 9 | TextDocument, 10 | Range, 11 | Selection, 12 | Position, 13 | window, 14 | Uri, 15 | workspace, 16 | } from "vscode"; 17 | import { 18 | CompletionParams, 19 | HoverParams, 20 | TextDocumentIdentifier, 21 | TypeDefinitionParams, 22 | } from "vscode-languageserver-protocol"; 23 | import { 24 | extractContextFromHover, 25 | ExtractedCtx, 26 | findGraphQLTypeFromRecord, 27 | GqlCtx, 28 | } from "./contextUtilsNoVscode"; 29 | import * as path from "path"; 30 | import * as fs from "fs"; 31 | import { extractGraphQLSources } from "./findGraphQLSources"; 32 | import { GraphQLSourceFromTag } from "./extensionTypes"; 33 | import { loadRelayConfig } from "./loadSchema"; 34 | import * as lineReader from "line-reader"; 35 | import { GraphQLType } from "./contextUtilsNoVscode"; 36 | 37 | const logDebug = (txt: string) => { 38 | return; 39 | window.showInformationMessage(txt); 40 | }; 41 | 42 | export const sourceLocExtractor = new RegExp( 43 | /(?<=\/\* @sourceLoc )[A-Za-z_.0-9]+(?= \*\/)/g 44 | ); 45 | 46 | function getHoverCtx( 47 | selection: Range | Selection | Position, 48 | document: TextDocument, 49 | extensionPath: string 50 | ) { 51 | const position = selection instanceof Position ? selection : selection.start; 52 | const propNameRange = document.getWordRangeAtPosition(position); 53 | 54 | if (propNameRange == null) { 55 | return null; 56 | } 57 | 58 | const propName = document.getText(propNameRange); 59 | 60 | const params: HoverParams = { 61 | position: selection instanceof Position ? selection : selection.start, 62 | textDocument: { 63 | uri: document.uri.toString(), 64 | }, 65 | }; 66 | 67 | let res: string | null = null; 68 | 69 | try { 70 | res = 71 | runHoverCommand( 72 | { 73 | jsonrpc: "2.0", 74 | id: Math.random(), 75 | method: "hover", 76 | params, 77 | }, 78 | extensionPath 79 | // @ts-ignore 80 | ).result?.contents ?? null; 81 | } catch { 82 | logDebug(`Bailing because analysis command failed`); 83 | return null; 84 | } 85 | 86 | if (res == null) { 87 | return null; 88 | } 89 | 90 | return extractContextFromHover(propName, res); 91 | } 92 | 93 | export function extractContextFromTypeDefinition( 94 | selection: Position, 95 | document: TextDocument, 96 | uri: string, 97 | range: { 98 | start: { line: number; character: number }; 99 | end: { line: number; character: number }; 100 | } 101 | ): GqlCtx | null { 102 | /** 103 | * - Parse out current identifier (some regexp grabbing the last word until .) 104 | * - Combine with the name of the file itself 105 | */ 106 | const fileName = path.basename(uri); 107 | 108 | if (fileName.endsWith("_graphql.res")) { 109 | const graphqlName = fileName.slice( 110 | 0, 111 | fileName.length - "_graphql.res".length 112 | ); 113 | 114 | const propNameRange = document.getWordRangeAtPosition(selection); 115 | 116 | if (propNameRange == null) { 117 | return null; 118 | } 119 | 120 | const propName = document.getText(propNameRange); 121 | 122 | const file = fs.readFileSync(Uri.parse(uri).fsPath, "utf-8"); 123 | 124 | const targetContent = file 125 | .split("\n") 126 | .slice(range.start.line, range.end.line) 127 | .join("\n"); 128 | 129 | const recordName = targetContent.split(/(type |and )/g)[2].split(" =")[0]; 130 | const graphqlType = findGraphQLTypeFromRecord(recordName, graphqlName); 131 | 132 | return { 133 | type: "GraphQLValue", 134 | graphqlName, 135 | graphqlType, 136 | recordName: `${recordName}_${propName}`, 137 | propName, 138 | }; 139 | } 140 | 141 | return null; 142 | } 143 | 144 | function getTypeDefCtx( 145 | selection: Range | Selection | Position, 146 | document: TextDocument, 147 | extensionPath: string 148 | ) { 149 | const position = selection instanceof Position ? selection : selection.start; 150 | 151 | const params: TypeDefinitionParams = { 152 | position, 153 | textDocument: { 154 | uri: document.uri.toString(), 155 | }, 156 | }; 157 | 158 | let uri: string | null = null; 159 | let range: { 160 | start: { line: number; character: number }; 161 | end: { line: number; character: number }; 162 | } | null = null; 163 | 164 | try { 165 | const res = runTypeDefinitionCommand( 166 | { 167 | jsonrpc: "2.0", 168 | id: Math.random(), 169 | method: "textDocument/typeDefinition", 170 | params, 171 | }, 172 | extensionPath 173 | // @ts-ignore 174 | ).result as null | { 175 | uri: string; 176 | range: { 177 | start: { line: number; character: number }; 178 | end: { line: number; character: number }; 179 | }; 180 | }; 181 | 182 | if (res != null) { 183 | uri = res.uri; 184 | range = res.range; 185 | } 186 | } catch { 187 | logDebug(`Bailing because analysis command failed`); 188 | return null; 189 | } 190 | 191 | if (uri == null || range == null) { 192 | logDebug("uri or range was null"); 193 | return null; 194 | } 195 | 196 | return extractContextFromTypeDefinition(position, document, uri, range); 197 | } 198 | 199 | const makeCompletionCacheKey = (text: string, selection: Position): string => 200 | `${text}/${selection.line}:${selection.character}`; 201 | 202 | const completionCache = new LRU(5); 203 | 204 | export function complete(document: TextDocument, selection: Position) { 205 | const text = document.getText(); 206 | const completionCacheKey = makeCompletionCacheKey(text, selection); 207 | const cached = completionCache.get(completionCacheKey); 208 | 209 | if (cached) { 210 | return cached; 211 | } 212 | 213 | const extensionPath = extensions.getExtension("chenglou92.rescript-vscode") 214 | ?.extensionPath; 215 | 216 | if (!extensionPath) { 217 | logDebug(`Bailing because no extension path`); 218 | return null; 219 | } 220 | 221 | let r = null; 222 | 223 | try { 224 | const params: CompletionParams = { 225 | position: selection, 226 | textDocument: TextDocumentIdentifier.create(document.uri.toString()), 227 | }; 228 | 229 | const res = runCompletionCommand( 230 | { 231 | jsonrpc: "2.0", 232 | id: Math.random(), 233 | method: "textDocument/completion", 234 | params, 235 | }, 236 | text, 237 | extensionPath 238 | // @ts-ignore 239 | ).result as null | { label: string; detail: string }[]; 240 | 241 | if (res != null) { 242 | completionCache.set(completionCacheKey, res); 243 | r = res; 244 | } 245 | } catch (e) { 246 | logDebug(`Bailing because analysis command failed`); 247 | return null; 248 | } 249 | 250 | return r; 251 | } 252 | 253 | type GraphQLValueContext = { 254 | type: "GraphQLValueContext"; 255 | recordName: string; 256 | graphqlName: string; 257 | graphqlType: GraphQLType; 258 | sourceFilePath: string; 259 | tag: GraphQLSourceFromTag; 260 | propName: string; 261 | }; 262 | 263 | type RescriptRelayValueContext = { 264 | type: "RescriptRelayValueContext"; 265 | value: "dataId"; 266 | }; 267 | 268 | export type Context = GraphQLValueContext | RescriptRelayValueContext; 269 | 270 | export async function findContext( 271 | document: TextDocument, 272 | selection: Range | Selection | Position, 273 | allowFilesOutsideOfCurrent = false 274 | ): Promise { 275 | const extensionPath = extensions.getExtension("chenglou92.rescript-vscode") 276 | ?.extensionPath; 277 | 278 | if (!extensionPath) { 279 | logDebug(`Bailing because no extension path`); 280 | return null; 281 | } 282 | 283 | let ctx: ExtractedCtx | null = getHoverCtx( 284 | selection, 285 | document, 286 | extensionPath 287 | ); 288 | 289 | if (ctx == null) { 290 | ctx = getTypeDefCtx(selection, document, extensionPath); 291 | } 292 | 293 | if (ctx == null) { 294 | logDebug("Got no typedef"); 295 | return null; 296 | } 297 | 298 | // Ok, we have the fragment name and type name. Let's look up the source for 299 | // it, and actual GraphQL document. 300 | 301 | let sourceFilePath: string | null = null; 302 | let docText = ""; 303 | const theCtx = ctx; 304 | 305 | if (theCtx.type === "RescriptRelayValue") { 306 | return { 307 | type: "RescriptRelayValueContext", 308 | value: theCtx.value, 309 | }; 310 | } 311 | 312 | if ( 313 | theCtx.graphqlName.startsWith(path.basename(document.uri.fsPath, ".res")) 314 | ) { 315 | // This is from the same file we're in 316 | sourceFilePath = document.uri.fsPath; 317 | docText = document.getText(); 318 | } else if (allowFilesOutsideOfCurrent) { 319 | const sourceLoc = await getSourceLocOfGraphQL(theCtx.graphqlName); 320 | 321 | if (sourceLoc != null) { 322 | const [fileUri] = await workspace.findFiles( 323 | `**/${sourceLoc.fileName}`, 324 | null, 325 | 1 326 | ); 327 | 328 | if (fileUri != null) { 329 | sourceFilePath = fileUri.fsPath; 330 | docText = fs.readFileSync(sourceFilePath, "utf-8"); 331 | } 332 | } 333 | } 334 | 335 | if (sourceFilePath == null) { 336 | logDebug(`Bailing because no source file path`); 337 | return null; 338 | } 339 | 340 | const tag = extractGraphQLSources(docText)?.find( 341 | (t) => 342 | t.type === "TAG" && 343 | t.content.includes(`${theCtx.graphqlType} ${theCtx.graphqlName}`) 344 | ); 345 | 346 | if (tag == null || tag.type !== "TAG") { 347 | logDebug(`Bailing because no tag`); 348 | return null; 349 | } 350 | 351 | return { 352 | type: "GraphQLValueContext", 353 | sourceFilePath, 354 | tag, 355 | graphqlName: theCtx.graphqlName, 356 | // @ts-ignore oh how I love you TS 357 | graphqlType: theCtx.graphqlType, 358 | propName: theCtx.propName, 359 | recordName: theCtx.recordName, 360 | }; 361 | } 362 | 363 | export async function getSourceLocOfGraphQL( 364 | opName: string 365 | ): Promise<{ fileName: string; componentName: string } | null> { 366 | const relayConfig = await loadRelayConfig(); 367 | 368 | if (relayConfig == null) { 369 | return null; 370 | } 371 | 372 | return new Promise((resolve) => { 373 | let i = 0; 374 | 375 | lineReader.eachLine( 376 | path.resolve( 377 | path.join(relayConfig.artifactDirectory, `${opName}_graphql.res`) 378 | ), 379 | (line) => { 380 | i += 1; 381 | 382 | const sourceLoc = line.match(sourceLocExtractor)?.[0]; 383 | if (sourceLoc != null) { 384 | resolve({ 385 | fileName: sourceLoc, 386 | componentName: `${sourceLoc[0].toUpperCase()}${sourceLoc.slice( 387 | 1, 388 | sourceLoc.length - 4 389 | )}`, 390 | }); 391 | return false; 392 | } 393 | 394 | if (i > 3) { 395 | resolve(null); 396 | return false; 397 | } 398 | } 399 | ); 400 | }); 401 | } 402 | 403 | export async function getFragmentDefinition( 404 | fragmentName: string 405 | ): Promise<{ 406 | fileName: string; 407 | fileLocation: Uri; 408 | componentName: string; 409 | tag: GraphQLSourceFromTag; 410 | } | null> { 411 | const sourceLoc = await getSourceLocOfGraphQL(fragmentName); 412 | 413 | if (sourceLoc == null) { 414 | return null; 415 | } 416 | let sourceFilePath: Uri | null = null; 417 | let docText = ""; 418 | 419 | const [fileUri] = await workspace.findFiles( 420 | `**/${sourceLoc.fileName}`, 421 | null, 422 | 1 423 | ); 424 | 425 | if (fileUri != null) { 426 | sourceFilePath = fileUri; 427 | docText = fs.readFileSync(sourceFilePath.fsPath, "utf-8"); 428 | } 429 | 430 | if (sourceFilePath == null || docText == null) { 431 | return null; 432 | } 433 | 434 | const tag = extractGraphQLSources(docText)?.find((source) => { 435 | if ( 436 | source.type === "TAG" && 437 | source.content.includes(`fragment ${fragmentName}`) 438 | ) { 439 | return true; 440 | } 441 | 442 | return false; 443 | }) as GraphQLSourceFromTag | undefined; 444 | 445 | if (tag == null) { 446 | return null; 447 | } 448 | 449 | return { 450 | ...sourceLoc, 451 | fileLocation: sourceFilePath, 452 | tag, 453 | }; 454 | } 455 | -------------------------------------------------------------------------------- /src/contextUtilsNoVscode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ASTNode, 3 | visitWithTypeInfo, 4 | TypeInfo, 5 | visit, 6 | parse, 7 | GraphQLSchema, 8 | GraphQLCompositeType, 9 | getLocation, 10 | SourceLocation, 11 | getNamedType, 12 | FragmentDefinitionNode, 13 | FieldNode, 14 | InlineFragmentNode, 15 | DocumentNode, 16 | GraphQLObjectType, 17 | SelectionSetNode, 18 | SelectionNode, 19 | GraphQLInterfaceType, 20 | GraphQLUnionType, 21 | GraphQLNamedType, 22 | GraphQLScalarType, 23 | GraphQLEnumType, 24 | GraphQLInputObjectType, 25 | OperationDefinitionNode, 26 | } from "graphql"; 27 | import { makeFirstFieldSelection } from "./graphqlUtilsNoVscode"; 28 | 29 | export type GraphQLType = "query" | "fragment" | "subscription" | "mutation"; 30 | 31 | type RescriptRelayValueCtx = { 32 | type: "RescriptRelayValue"; 33 | value: "dataId"; 34 | }; 35 | 36 | export type GqlCtx = { 37 | type: "GraphQLValue"; 38 | recordName: string; 39 | graphqlName: string; 40 | graphqlType: GraphQLType; 41 | propName: string; 42 | }; 43 | 44 | export type ExtractedCtx = GqlCtx | RescriptRelayValueCtx; 45 | 46 | export function findGraphQLTypeFromRecord( 47 | recordName: string, 48 | graphqlName: string 49 | ): GraphQLType { 50 | let graphqlType: GraphQLType = "fragment"; 51 | 52 | if (recordName.startsWith("response")) { 53 | const graphqlNameLc = graphqlName.toLowerCase(); 54 | if (graphqlNameLc.endsWith("mutation")) { 55 | graphqlType = "mutation"; 56 | } else if (graphqlNameLc.endsWith("subscription")) { 57 | graphqlType = "subscription"; 58 | } else if (graphqlNameLc.endsWith("query")) { 59 | graphqlType = "query"; 60 | } 61 | } else if (recordName.startsWith("fragment")) { 62 | graphqlType = "fragment"; 63 | } 64 | 65 | return graphqlType; 66 | } 67 | 68 | export const findRecordAndModulesFromCompletion = (completionItem: { 69 | label: string; 70 | detail: string; 71 | }): null | { 72 | label: string; 73 | module: string; 74 | graphqlName: string; 75 | graphqlType: GraphQLType; 76 | recordName: string; 77 | } => { 78 | const extracted = completionItem.detail.match( 79 | /(\w+)_graphql(?:.|(?:-\w+".))Types.(\w+)/ 80 | ); 81 | 82 | if (extracted != null && extracted.length === 3) { 83 | const graphqlName = extracted[1]; 84 | const recordName = extracted[2]; 85 | const graphqlType = findGraphQLTypeFromRecord(recordName, graphqlName); 86 | 87 | return { 88 | label: completionItem.label, 89 | module: `${graphqlName}_graphql`, 90 | graphqlName, 91 | graphqlType, 92 | recordName, 93 | }; 94 | } 95 | return null; 96 | }; 97 | 98 | export function extractContextFromHover( 99 | propName: string, 100 | hoverContents: string 101 | ): ExtractedCtx | null { 102 | if ( 103 | hoverContents.includes(`\`\`\`rescript 104 | RescriptRelay.dataId`) 105 | ) { 106 | return { 107 | type: "RescriptRelayValue", 108 | value: "dataId", 109 | }; 110 | } 111 | 112 | if (hoverContents.includes(" => ")) { 113 | return null; 114 | } 115 | 116 | let res; 117 | 118 | let graphqlName: string | null = null; 119 | let recordName: string | null = null; 120 | let graphqlType: GraphQLType | null = null; 121 | 122 | const opFragmentNameExtractorRegexp = /(\w+)_graphql(?:.|(?:-\w+".))Types.(\w+)/g; 123 | 124 | while ((res = opFragmentNameExtractorRegexp.exec(hoverContents)) !== null) { 125 | graphqlName = res[1] ?? null; 126 | recordName = res[2] ?? null; 127 | 128 | graphqlType = findGraphQLTypeFromRecord(recordName, graphqlName); 129 | 130 | // A (weird) heuristic for unions 131 | if ( 132 | hoverContents.startsWith("```rescript\n[") && 133 | hoverContents.includes("UnselectedUnionMember(") 134 | ) { 135 | const parts = recordName.split("_"); 136 | recordName = parts.slice(0, parts.length - 1).join("_"); 137 | } 138 | 139 | if (graphqlName != null && recordName != null) { 140 | break; 141 | } 142 | } 143 | 144 | if (graphqlName == null || recordName == null || graphqlType == null) { 145 | return null; 146 | } 147 | 148 | return { 149 | type: "GraphQLValue", 150 | recordName, 151 | graphqlName, 152 | graphqlType, 153 | propName, 154 | }; 155 | } 156 | 157 | const getNameForNode = (node: ASTNode) => { 158 | switch (node.kind) { 159 | case "Field": 160 | return node.alias ? node.alias.value : node.name.value; 161 | case "InlineFragment": 162 | return node.typeCondition?.name.value; 163 | } 164 | }; 165 | 166 | const getNamedPath = ( 167 | ancestors: ReadonlyArray> | null, 168 | node: ASTNode, 169 | graphqlType: GraphQLType 170 | ): string => { 171 | const paths = (ancestors || []).reduce((acc: string[], next) => { 172 | if (Array.isArray(next)) { 173 | return acc; 174 | } 175 | const node = next as ASTNode; 176 | 177 | switch (node.kind) { 178 | case "Field": 179 | return [...acc, node.name.value]; 180 | case "InlineFragment": 181 | return [...acc, node.typeCondition?.name.value ?? ""]; 182 | default: 183 | return acc; 184 | } 185 | }, []); 186 | 187 | return [ 188 | graphqlType === "fragment" ? "fragment" : "response", 189 | ...paths, 190 | getNameForNode(node), 191 | ] 192 | .filter(Boolean) 193 | .join("_"); 194 | }; 195 | 196 | export const getConnectionKeyName = ( 197 | ancestors: ReadonlyArray> | null, 198 | node: ASTNode, 199 | fragmentName: string 200 | ): string => { 201 | const paths = (ancestors || []).reduce((acc: string[], next) => { 202 | if (Array.isArray(next)) { 203 | return acc; 204 | } 205 | const node = next as ASTNode; 206 | 207 | switch (node.kind) { 208 | case "Field": 209 | return [...acc, node.name.value]; 210 | case "InlineFragment": 211 | return [...acc, node.typeCondition?.name.value ?? ""]; 212 | default: 213 | return acc; 214 | } 215 | }, []); 216 | 217 | return [fragmentName, ...paths, getNameForNode(node)] 218 | .filter(Boolean) 219 | .join("_"); 220 | }; 221 | 222 | export interface GraphQLRecordCtx { 223 | type: GraphQLNamedType; 224 | description: string | null; 225 | fieldTypeAsString: string; 226 | startLoc?: SourceLocation | null; 227 | endLoc?: SourceLocation | null; 228 | astNode: FragmentDefinitionNode | FieldNode | InlineFragmentNode | null; 229 | parsedSource: DocumentNode; 230 | } 231 | 232 | export const findGraphQLRecordContext = ( 233 | src: string, 234 | recordName: string, 235 | schema: GraphQLSchema, 236 | graphqlType: GraphQLType 237 | ): null | GraphQLRecordCtx => { 238 | const parsed = parse(src); 239 | 240 | let typeOfThisThing: GraphQLNamedType | null = null; 241 | let astNode: 242 | | OperationDefinitionNode 243 | | FragmentDefinitionNode 244 | | FieldNode 245 | | InlineFragmentNode 246 | | null = null; 247 | let description: string | null = null; 248 | let fieldTypeAsString: string | null = null; 249 | let startLoc: SourceLocation | null = null; 250 | let endLoc; 251 | 252 | const typeInfo = new TypeInfo(schema); 253 | 254 | const checkNode = ( 255 | node: 256 | | FragmentDefinitionNode 257 | | FieldNode 258 | | InlineFragmentNode 259 | | OperationDefinitionNode, 260 | ancestors: any, 261 | graphqlType: GraphQLType 262 | ) => { 263 | const namedPath = getNamedPath(ancestors, node, graphqlType); 264 | 265 | if (namedPath === recordName) { 266 | const type = typeInfo.getType(); 267 | fieldTypeAsString = type?.toString() ?? null; 268 | const namedType = type ? getNamedType(type) : null; 269 | const fieldDef = typeInfo.getFieldDef(); 270 | 271 | if (type != null && namedType != null) { 272 | typeOfThisThing = namedType; 273 | astNode = node; 274 | 275 | description = fieldDef?.description ?? null; 276 | 277 | // Don't include docs for built in types 278 | if ( 279 | description == null && 280 | !["ID", "String", "Boolean", "Int", "Float"].includes(namedType.name) 281 | ) { 282 | description = namedType.description ?? null; 283 | } 284 | 285 | if (node.loc != null && startLoc == null) { 286 | startLoc = getLocation(node.loc.source, node.loc.start); 287 | endLoc = getLocation(node.loc.source, node.loc.end); 288 | } 289 | } 290 | } 291 | }; 292 | 293 | const visitor = visitWithTypeInfo(typeInfo, { 294 | Field(node, _a, _b, _c, ancestors) { 295 | checkNode(node, ancestors, graphqlType); 296 | }, 297 | InlineFragment(node, _a, _b, _c, ancestors) { 298 | checkNode(node, ancestors, graphqlType); 299 | }, 300 | FragmentDefinition(node) { 301 | checkNode(node, [], graphqlType); 302 | }, 303 | OperationDefinition(node) { 304 | checkNode(node, [], graphqlType); 305 | }, 306 | }); 307 | 308 | visit(parsed, visitor); 309 | 310 | if (typeOfThisThing == null || fieldTypeAsString == null || astNode == null) { 311 | return null; 312 | } 313 | 314 | return { 315 | astNode, 316 | type: typeOfThisThing, 317 | fieldTypeAsString, 318 | description, 319 | startLoc, 320 | endLoc, 321 | parsedSource: parsed, 322 | }; 323 | }; 324 | 325 | export function addFieldAtPosition( 326 | parsedSrc: DocumentNode, 327 | targetRecordName: string, 328 | parentType: GraphQLCompositeType, 329 | fieldName: string, 330 | graphqlType: GraphQLType 331 | ) { 332 | if (parentType instanceof GraphQLObjectType === false) { 333 | return parsedSrc; 334 | } 335 | 336 | const type = parentType as GraphQLObjectType; 337 | const field = Object.values(type.getFields()).find( 338 | (field) => field.name === fieldName 339 | ); 340 | 341 | if (field == null) { 342 | return parsedSrc; 343 | } 344 | 345 | const namedFieldType = getNamedType(field.type); 346 | 347 | let hasAddedField = false; 348 | 349 | const resolveNode = ( 350 | node: 351 | | FieldNode 352 | | FragmentDefinitionNode 353 | | InlineFragmentNode 354 | | OperationDefinitionNode, 355 | ancestors: any 356 | ): 357 | | FieldNode 358 | | FragmentDefinitionNode 359 | | InlineFragmentNode 360 | | OperationDefinitionNode => { 361 | if (hasAddedField) { 362 | return node; 363 | } 364 | 365 | const namedPath = getNamedPath(ancestors, node, graphqlType); 366 | 367 | if (namedPath === targetRecordName) { 368 | const selections: SelectionNode[] = []; 369 | 370 | if ( 371 | namedFieldType instanceof GraphQLObjectType || 372 | namedFieldType instanceof GraphQLInterfaceType || 373 | namedFieldType instanceof GraphQLUnionType 374 | ) { 375 | selections.push(...makeFirstFieldSelection(namedFieldType)); 376 | } 377 | 378 | hasAddedField = true; 379 | 380 | const newFieldNode: SelectionNode = { 381 | kind: "Field", 382 | name: { 383 | kind: "Name", 384 | value: field.name, 385 | }, 386 | selectionSet: { 387 | kind: "SelectionSet", 388 | selections, 389 | }, 390 | }; 391 | 392 | const newSelectionSet: SelectionSetNode = { 393 | kind: "SelectionSet", 394 | ...node.selectionSet, 395 | selections: [...(node.selectionSet?.selections ?? []), newFieldNode], 396 | }; 397 | 398 | return { 399 | ...node, 400 | selectionSet: newSelectionSet, 401 | }; 402 | } 403 | 404 | return node; 405 | }; 406 | 407 | const newSrc = visit(parsedSrc, { 408 | OperationDefinition(node, _a, _b, _c, ancestors) { 409 | return resolveNode(node, ancestors); 410 | }, 411 | FragmentDefinition(node, _a, _b, _c, ancestors) { 412 | return resolveNode(node, ancestors); 413 | }, 414 | InlineFragment(node, _a, _b, _c, ancestors) { 415 | return resolveNode(node, ancestors); 416 | }, 417 | Field(node, _a, _b, _c, ancestors) { 418 | return resolveNode(node, ancestors); 419 | }, 420 | }); 421 | 422 | return newSrc; 423 | } 424 | 425 | export function addFragmentSpreadAtPosition( 426 | parsedSrc: DocumentNode, 427 | targetRecordName: string, 428 | parentType: GraphQLCompositeType, 429 | fragmentName: string, 430 | graphqlType: GraphQLType 431 | ) { 432 | if ( 433 | parentType instanceof GraphQLObjectType === false && 434 | parentType instanceof GraphQLInterfaceType === false && 435 | parentType instanceof GraphQLUnionType === false 436 | ) { 437 | return null; 438 | } 439 | 440 | let hasAddedSpread = false; 441 | 442 | const resolveNode = ( 443 | node: 444 | | FieldNode 445 | | FragmentDefinitionNode 446 | | InlineFragmentNode 447 | | OperationDefinitionNode, 448 | ancestors: any 449 | ): 450 | | FieldNode 451 | | FragmentDefinitionNode 452 | | InlineFragmentNode 453 | | OperationDefinitionNode => { 454 | if (hasAddedSpread) { 455 | return node; 456 | } 457 | 458 | const namedPath = getNamedPath(ancestors, node, graphqlType); 459 | 460 | if (namedPath === targetRecordName) { 461 | hasAddedSpread = true; 462 | 463 | const newFieldNode: SelectionNode = { 464 | kind: "FragmentSpread", 465 | name: { 466 | kind: "Name", 467 | value: fragmentName, 468 | }, 469 | }; 470 | 471 | const newSelectionSet: SelectionSetNode = { 472 | kind: "SelectionSet", 473 | ...node.selectionSet, 474 | selections: [...(node.selectionSet?.selections ?? []), newFieldNode], 475 | }; 476 | 477 | return { 478 | ...node, 479 | selectionSet: newSelectionSet, 480 | }; 481 | } 482 | 483 | return node; 484 | }; 485 | 486 | const newSrc = visit(parsedSrc, { 487 | OperationDefinition(node, _a, _b, _c, ancestors) { 488 | return resolveNode(node, ancestors); 489 | }, 490 | FragmentDefinition(node, _a, _b, _c, ancestors) { 491 | return resolveNode(node, ancestors); 492 | }, 493 | InlineFragment(node, _a, _b, _c, ancestors) { 494 | return resolveNode(node, ancestors); 495 | }, 496 | Field(node, _a, _b, _c, ancestors) { 497 | return resolveNode(node, ancestors); 498 | }, 499 | }); 500 | 501 | return hasAddedSpread ? newSrc : null; 502 | } 503 | 504 | export function namedTypeToString(type: GraphQLNamedType): string { 505 | if (type instanceof GraphQLObjectType) { 506 | return "object"; 507 | } else if (type instanceof GraphQLUnionType) { 508 | return "union"; 509 | } else if (type instanceof GraphQLInterfaceType) { 510 | return "interface"; 511 | } else if (type instanceof GraphQLScalarType) { 512 | return "scalar"; 513 | } else if (type instanceof GraphQLEnumType) { 514 | return "enum"; 515 | } else if (type instanceof GraphQLInputObjectType) { 516 | return "input object"; 517 | } 518 | 519 | return "-"; 520 | } 521 | -------------------------------------------------------------------------------- /src/createNewFragmentComponentsUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | getLocation, 4 | getNamedType, 5 | GraphQLSchema, 6 | SelectionNode, 7 | SelectionSetNode, 8 | Source, 9 | TypeInfo, 10 | visit, 11 | visitWithTypeInfo, 12 | } from "graphql"; 13 | import { Position } from "vscode"; 14 | 15 | interface ExtractToFragmentConfig { 16 | normalizedSelection: [Position, Position]; 17 | parsedOp: DocumentNode; 18 | schema: GraphQLSchema; 19 | source: Source; 20 | } 21 | export const extractToFragment = ({ 22 | normalizedSelection, 23 | parsedOp, 24 | schema, 25 | source, 26 | }: ExtractToFragmentConfig): null | { 27 | selections: SelectionNode[]; 28 | targetSelection: SelectionSetNode; 29 | parentTypeName: string; 30 | } => { 31 | // Make selection into fragment component 32 | let parentTypeName: string | undefined; 33 | let targetSelection: SelectionSetNode | undefined; 34 | 35 | const [st, en] = normalizedSelection; 36 | 37 | const typeInfo = new TypeInfo(schema); 38 | 39 | const visitor = visitWithTypeInfo(typeInfo, { 40 | SelectionSet(node, _key, _parent, _t, _c) { 41 | const { loc } = node; 42 | 43 | if (!loc) { 44 | return; 45 | } 46 | 47 | const start = getLocation(source, loc.start); 48 | const end = getLocation(source, loc.end); 49 | 50 | if (st.line >= start.line && en.line <= end.line) { 51 | const thisType = typeInfo.getType(); 52 | 53 | if (thisType) { 54 | parentTypeName = getNamedType(thisType).name; 55 | targetSelection = node; 56 | } 57 | } 58 | }, 59 | }); 60 | 61 | visit(parsedOp, visitor); 62 | 63 | const selections: SelectionNode[] = targetSelection 64 | ? targetSelection.selections.filter((s: SelectionNode) => { 65 | if (s.loc) { 66 | const sLocStart = getLocation(source, s.loc.start); 67 | const sLocEnd = getLocation(source, s.loc.end); 68 | 69 | return sLocStart.line >= st.line && sLocEnd.line <= en.line; 70 | } 71 | 72 | return false; 73 | }) 74 | : []; 75 | 76 | if (!targetSelection || !parentTypeName) { 77 | return null; 78 | } 79 | 80 | return { selections, targetSelection, parentTypeName }; 81 | }; 82 | 83 | export const addFragmentHere = ({ 84 | normalizedSelection, 85 | parsedOp, 86 | schema, 87 | source, 88 | }: ExtractToFragmentConfig): null | { 89 | addBeforeThisSelection: SelectionNode | null; 90 | targetSelection: SelectionSetNode; 91 | parentTypeName: string; 92 | } => { 93 | // Make selection into fragment component 94 | let parentTypeName: string | undefined; 95 | let targetSelection: SelectionSetNode | undefined; 96 | 97 | const [st, en] = normalizedSelection; 98 | 99 | const typeInfo = new TypeInfo(schema); 100 | 101 | const visitor = visitWithTypeInfo(typeInfo, { 102 | SelectionSet(node, _key, _parent, _t, _c) { 103 | const { loc } = node; 104 | 105 | if (!loc) { 106 | return; 107 | } 108 | 109 | const start = getLocation(source, loc.start); 110 | const end = getLocation(source, loc.end); 111 | 112 | if (st.line >= start.line && en.line <= end.line) { 113 | const thisType = typeInfo.getType(); 114 | 115 | if (thisType) { 116 | parentTypeName = getNamedType(thisType).name; 117 | targetSelection = node; 118 | } 119 | } 120 | }, 121 | }); 122 | 123 | visit(parsedOp, visitor); 124 | 125 | const addBeforeThisSelection: SelectionNode | null = targetSelection 126 | ? targetSelection.selections.find((s: SelectionNode) => { 127 | if (s.loc) { 128 | const sLocStart = getLocation(source, s.loc.start); 129 | const sLocEnd = getLocation(source, s.loc.end); 130 | 131 | return sLocStart.line >= st.line && sLocEnd.line <= en.line; 132 | } 133 | 134 | return false; 135 | }) ?? null 136 | : null; 137 | 138 | if (!targetSelection || !parentTypeName) { 139 | return null; 140 | } 141 | 142 | return { 143 | addBeforeThisSelection, 144 | targetSelection, 145 | parentTypeName, 146 | }; 147 | }; 148 | -------------------------------------------------------------------------------- /src/extensionTypes.ts: -------------------------------------------------------------------------------- 1 | export type RawSchema = { 2 | content: string; 3 | type: "json" | "sdl"; 4 | }; 5 | 6 | export type SchemaLoader = ( 7 | rootPath: string, 8 | filesInRoot: Array 9 | ) => Promise; 10 | 11 | export type GraphQLSourceFromFullDocument = { 12 | type: "FULL_DOCUMENT"; 13 | content: string; 14 | }; 15 | 16 | export type GraphQLSourceFromTag = { 17 | type: "TAG"; 18 | moduleName: string; 19 | content: string; 20 | start: { 21 | line: number; 22 | character: number; 23 | }; 24 | end: { 25 | line: number; 26 | character: number; 27 | }; 28 | }; 29 | 30 | export type GraphQLSource = 31 | | GraphQLSourceFromFullDocument 32 | | GraphQLSourceFromTag; 33 | 34 | export type InsertGraphQLComponentType = 35 | | "Fragment" 36 | | "Query" 37 | | "Mutation" 38 | | "Subscription"; 39 | -------------------------------------------------------------------------------- /src/extensionUtils.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from "prettier/standalone"; 2 | import * as parserGraphql from "prettier/parser-graphql"; 3 | import { 4 | window, 5 | commands, 6 | Range, 7 | Selection, 8 | Position, 9 | CompletionItem, 10 | Uri, 11 | env, 12 | TextDocument, 13 | ViewColumn, 14 | workspace, 15 | } from "vscode"; 16 | import { GraphQLSourceFromTag } from "./extensionTypes"; 17 | import { getSourceLocOfGraphQL } from "./contextUtils"; 18 | import * as path from "path"; 19 | import { validateRescriptVariableName } from "./extensionUtilsNoVscode"; 20 | import { getPreferredFragmentPropName } from "./utils"; 21 | import { GraphQLCompositeType, isCompositeType } from "graphql"; 22 | import { pickTypeForFragment } from "./graphqlUtils"; 23 | import { loadFullSchema } from "./loadSchema"; 24 | 25 | export const getNormalizedSelection = ( 26 | range: Range, 27 | selectedOp: GraphQLSourceFromTag 28 | ): [Position, Position] => { 29 | const start = new Position( 30 | range.start.line - selectedOp.start.line + 1, 31 | range.start.character 32 | ); 33 | 34 | const end = new Position( 35 | range.end.line - selectedOp.start.line + 1, 36 | range.end.character 37 | ); 38 | 39 | if (start.isBeforeOrEqual(end)) { 40 | return [start, end]; 41 | } 42 | 43 | return [end, start]; 44 | }; 45 | 46 | export function prettify(str: string): string { 47 | return ( 48 | prettier 49 | .format(str, { 50 | parser: "graphql", 51 | plugins: [parserGraphql], 52 | }) 53 | /** 54 | * Prettier adds a new line to the output by design. 55 | * This circumvents that as it messes things up. 56 | */ 57 | .replace(/^\s+|\s+$/g, "") 58 | ); 59 | } 60 | 61 | export const padOperation = (operation: string, indentation: number): string => 62 | operation 63 | .split("\n") 64 | .map((s: string) => " ".repeat(indentation) + s) 65 | .join("\n"); 66 | 67 | const initialWhitespaceRegexp = new RegExp(/^[\s]*(?=[\w])/g); 68 | const endingWhitespaceRegexp = new RegExp(/[\s]*$/g); 69 | 70 | export const findOperationPadding = (operation: string): number => { 71 | const initialWhitespace = ( 72 | operation.match(initialWhitespaceRegexp) || [] 73 | ).pop(); 74 | const firstRelevantLine = (initialWhitespace || "").split("\n").pop(); 75 | 76 | return firstRelevantLine ? firstRelevantLine.length : 0; 77 | }; 78 | 79 | export const restoreOperationPadding = ( 80 | operation: string, 81 | initialOperation: string 82 | ): string => { 83 | const endingWhitespace = ( 84 | initialOperation.match(endingWhitespaceRegexp) || [] 85 | ).join(""); 86 | 87 | return ( 88 | "\n" + 89 | padOperation(operation, findOperationPadding(initialOperation)) + 90 | endingWhitespace 91 | ); 92 | }; 93 | 94 | export function capitalize(str: string): string { 95 | return str.slice(0, 1).toUpperCase() + str.slice(1); 96 | } 97 | 98 | export function uncapitalize(str: string): string { 99 | return str.slice(0, 1).toLowerCase() + str.slice(1); 100 | } 101 | 102 | export function waitFor(time: number): Promise { 103 | return new Promise((resolve) => { 104 | setTimeout(resolve, time); 105 | }); 106 | } 107 | 108 | export async function wrapInJsx( 109 | start: string, 110 | end: string, 111 | endSelectionOffset: number 112 | ) { 113 | const textEditor = window.activeTextEditor; 114 | 115 | if (textEditor) { 116 | const [currentSelection] = textEditor.selections; 117 | const hasSelectedRange = !currentSelection.start.isEqual( 118 | currentSelection.end 119 | ); 120 | 121 | if (!hasSelectedRange) { 122 | await commands.executeCommand("editor.emmet.action.balanceOut"); 123 | } 124 | 125 | const [first] = textEditor.selections; 126 | 127 | if (!first.start.isEqual(first.end)) { 128 | const selectedRange = new Range(first.start, first.end); 129 | const text = textEditor.document.getText(selectedRange); 130 | 131 | await textEditor.edit((editBuilder) => { 132 | editBuilder.replace(selectedRange, `${start}${text}${end}`); 133 | }); 134 | 135 | const endPos = first.start.with( 136 | undefined, 137 | first.start.character + endSelectionOffset 138 | ); 139 | 140 | const endSelection = new Selection(endPos, endPos); 141 | 142 | textEditor.selections = [endSelection]; 143 | } 144 | } 145 | } 146 | 147 | export const createCompletionItemsForFragmentSpreads = ( 148 | label: string, 149 | spreads: string[] 150 | ) => { 151 | const items: CompletionItem[] = []; 152 | 153 | spreads.forEach((spread) => { 154 | const item = new CompletionItem(`${label}: ${spread}`); 155 | item.sortText = `zz ${label} ${spread}`; 156 | item.detail = `Component for \`${spread}\``; 157 | 158 | // @ts-ignore 159 | item.__extra = { 160 | label: label, 161 | fragmentName: spread, 162 | }; 163 | 164 | items.push(item); 165 | }); 166 | 167 | return items; 168 | }; 169 | 170 | export const fillInFileDataForFragmentSpreadCompletionItems = async ( 171 | items: CompletionItem[], 172 | viaCommand = false 173 | ) => { 174 | const processedItems: (CompletionItem | null)[] = await Promise.all( 175 | items.map(async (item) => { 176 | const extra: { label: string; fragmentName: string } = (item as any) 177 | .__extra; 178 | 179 | const sourceLoc = await getSourceLocOfGraphQL(extra.fragmentName); 180 | 181 | if (sourceLoc == null) { 182 | return null; 183 | } 184 | 185 | // Infer propname 186 | const propName = extra.fragmentName.split("_").pop() ?? extra.label; 187 | 188 | if (viaCommand) { 189 | item.insertText = ""; 190 | item.command = { 191 | command: "vscode-rescript-relay.replace-current-dot-completion", 192 | title: "", 193 | arguments: [ 194 | (symbol: string) => 195 | `<${sourceLoc.componentName} ${propName}={${symbol}.fragmentRefs} />`, 196 | ], 197 | }; 198 | } else { 199 | item.insertText = `<${sourceLoc.componentName} ${propName}={${extra.label}.fragmentRefs} />`; 200 | } 201 | 202 | return item; 203 | }) 204 | ); 205 | 206 | return processedItems.length > 0 207 | ? (processedItems.filter(Boolean) as CompletionItem[]) 208 | : null; 209 | }; 210 | 211 | export function getModuleNameFromFile(uri: Uri): string { 212 | return capitalize(path.basename(uri.path, ".res")); 213 | } 214 | 215 | export enum FragmentCreationSource { 216 | GraphQLTag, 217 | Value, 218 | NewFile, 219 | CodegenInFile, 220 | } 221 | 222 | type FragmentCreationWizardConfig = { 223 | uri: Uri; 224 | selectedVariableName: string; 225 | type: GraphQLCompositeType; 226 | source: FragmentCreationSource; 227 | }; 228 | 229 | export async function fragmentCreationWizard({ 230 | uri, 231 | selectedVariableName, 232 | type, 233 | source, 234 | }: FragmentCreationWizardConfig) { 235 | let newComponentName = ""; 236 | 237 | if (source === FragmentCreationSource.CodegenInFile) { 238 | newComponentName = getModuleNameFromFile(uri); 239 | } else { 240 | newComponentName = (await window.showInputBox({ 241 | prompt: "Name of your new component", 242 | value: getModuleNameFromFile(uri), 243 | validateInput(v: string): string | null { 244 | return /^[a-zA-Z0-9_]*$/.test(v) 245 | ? null 246 | : "Please only use alphanumeric characters and underscores."; 247 | }, 248 | })) as string; 249 | 250 | if (!newComponentName) { 251 | window.showWarningMessage("Your component must have a name."); 252 | return null; 253 | } 254 | } 255 | 256 | let typ = type; 257 | 258 | if ( 259 | source === FragmentCreationSource.NewFile || 260 | source === FragmentCreationSource.CodegenInFile 261 | ) { 262 | const typeName = await pickTypeForFragment(); 263 | const schema = await loadFullSchema(); 264 | const theType = schema?.getType(typeName ?? ""); 265 | 266 | if (theType != null && isCompositeType(theType)) { 267 | typ = theType; 268 | } 269 | } 270 | 271 | let theSelectedVariableName = 272 | selectedVariableName != null && selectedVariableName !== "" 273 | ? selectedVariableName 274 | : uncapitalize(getPreferredFragmentPropName(typ.name)); 275 | 276 | const variableName = 277 | (await window.showInputBox({ 278 | prompt: `What do you want to call the prop name for the fragment?`, 279 | value: theSelectedVariableName, 280 | validateInput: (input) => { 281 | if (input === "" || !validateRescriptVariableName(input)) { 282 | return "Invalid ReScript variable name"; 283 | } 284 | }, 285 | })) ?? uncapitalize(getPreferredFragmentPropName(typ.name)); 286 | 287 | let copyToClipboard = false; 288 | let shouldOpenFile = "No"; 289 | 290 | if (source !== FragmentCreationSource.CodegenInFile) { 291 | shouldOpenFile = 292 | (await window.showQuickPick( 293 | ["Yes, in the current editor", "Yes, to the right", "No"], 294 | { 295 | placeHolder: "Do you want to open the new file directly?", 296 | } 297 | )) ?? "No"; 298 | 299 | copyToClipboard = 300 | (await window.showQuickPick(["Yes", "No"], { 301 | placeHolder: 302 | "Do you want to copy the JSX for using the new component to the clipboard?", 303 | })) === "Yes"; 304 | } 305 | 306 | let shouldRemoveSelection = false; 307 | 308 | if (source === FragmentCreationSource.GraphQLTag) { 309 | shouldRemoveSelection = 310 | (await window.showQuickPick(["Yes", "No"], { 311 | placeHolder: "Do you want to remove the selection from this fragment?", 312 | })) === "Yes"; 313 | } 314 | 315 | const fragmentName = `${capitalize( 316 | newComponentName.replace(/_/g, "") 317 | )}_${uncapitalize(variableName)}`; 318 | 319 | return { 320 | shouldOpenFile, 321 | fragmentName, 322 | copyToClipboard, 323 | newComponentName, 324 | variableName, 325 | shouldRemoveSelection, 326 | type: typ, 327 | }; 328 | } 329 | 330 | export function copyComponentCodeToClipboard(text: string) { 331 | env.clipboard.writeText(text); 332 | window.showInformationMessage( 333 | `Code for your new component has been copied to the clipboard.` 334 | ); 335 | } 336 | 337 | export function openFileAndShowMessage({ 338 | shouldOpenFile, 339 | doc, 340 | newComponentName, 341 | }: { 342 | shouldOpenFile: string; 343 | doc: TextDocument; 344 | newComponentName: string; 345 | }) { 346 | const msg = `"${newComponentName}.res" was created with your new fragment.`; 347 | 348 | if (shouldOpenFile === "Yes, in the current editor") { 349 | window.showTextDocument(doc); 350 | } else if (shouldOpenFile === "Yes, to the right") { 351 | window.showTextDocument(doc, ViewColumn.Beside, true); 352 | } else if (shouldOpenFile === "No") { 353 | window.showInformationMessage(msg, "Open file").then((m) => { 354 | if (m) { 355 | window.showTextDocument(doc); 356 | } 357 | }); 358 | } 359 | } 360 | 361 | export function makeNewFragmentComponentJsx({ 362 | newComponentName, 363 | propName, 364 | variableName, 365 | }: { 366 | newComponentName: string; 367 | propName: string; 368 | variableName: string; 369 | }) { 370 | return `<${newComponentName} ${propName}=${variableName}.fragmentRefs />`; 371 | } 372 | 373 | export const openPosInDoc = async ( 374 | rawUri: string, 375 | line: number, 376 | column: number 377 | ) => { 378 | const textDoc = await workspace.openTextDocument(rawUri); 379 | 380 | await window.showTextDocument(textDoc, { 381 | selection: new Range( 382 | new Position(line, column), 383 | new Position(line, column) 384 | ), 385 | }); 386 | }; 387 | -------------------------------------------------------------------------------- /src/extensionUtilsNoVscode.ts: -------------------------------------------------------------------------------- 1 | const validReScriptVariableNameRegexp = new RegExp(/^[a-z][a-zA-Z_0-9]+$/); 2 | 3 | export const validateRescriptVariableName = (str: string): boolean => 4 | validReScriptVariableNameRegexp.test(str); 5 | 6 | export const extractFragmentRefs = (str: string): string[] => { 7 | const regex = /RescriptRelay\.fragmentRefs<\s*\[(.*)\]/gm; 8 | let m: any; 9 | const res: string[] = []; 10 | const theStr = str.replace(/\n/g, ""); 11 | 12 | while ((m = regex.exec(theStr)) !== null) { 13 | if (m.index === regex.lastIndex) { 14 | regex.lastIndex++; 15 | } 16 | 17 | if (m[1] != null) { 18 | const raw = m[1].trim(); 19 | const extrRegex = /#(\w+)/g; 20 | let f: any; 21 | while ((f = extrRegex.exec(raw)) !== null) { 22 | res.push(f[1].replace("#", "")); 23 | } 24 | } 25 | } 26 | 27 | return res.reduce((acc: string[], curr: string) => { 28 | if (acc.includes(curr)) { 29 | return acc; 30 | } 31 | 32 | acc.push(curr); 33 | return acc; 34 | }, []); 35 | }; 36 | -------------------------------------------------------------------------------- /src/findGraphQLSources.ts: -------------------------------------------------------------------------------- 1 | import { getLocator } from "locate-character"; 2 | import { GraphQLSource, GraphQLSourceFromTag } from "./extensionTypes"; 3 | import LRUCache from "lru-cache"; 4 | 5 | /** 6 | * A helper for extracting GraphQL operations from source via a regexp. 7 | * It assumes that the only thing the regexp matches is the actual content, 8 | * so if that's not true for your regexp you probably shouldn't use this 9 | * directly. 10 | */ 11 | export let makeExtractTagsFromSource = ( 12 | regexp: RegExp 13 | ): ((text: string) => Array) => ( 14 | text: string 15 | ): Array => { 16 | const locator = getLocator(text); 17 | const sources: Array = []; 18 | const asLines = text.split("\n"); 19 | 20 | let result; 21 | while ((result = regexp.exec(text)) !== null) { 22 | let start = locator(result.index); 23 | let end = locator(result.index + result[0].length); 24 | 25 | // Figure out the module name. Given the formatter, it'll be on the same or 26 | // previous line. 27 | let moduleName = "UnknownModule"; 28 | 29 | const matchLineWithModuleName = (line: string) => 30 | line.match(/module (\w+) =/)?.[1]; 31 | 32 | if (asLines[start.line]?.includes("module ")) { 33 | moduleName = 34 | matchLineWithModuleName(asLines[start.line]) ?? "UnknownModule"; 35 | } 36 | 37 | if (asLines[start.line - 1]?.includes("module ")) { 38 | moduleName = 39 | matchLineWithModuleName(asLines[start.line - 1]) ?? "UnknownModule"; 40 | } 41 | 42 | sources.push({ 43 | type: "TAG", 44 | moduleName, 45 | content: result[0], 46 | start: { 47 | line: start.line, 48 | character: start.column, 49 | }, 50 | end: { 51 | line: end.line, 52 | character: end.column, 53 | }, 54 | }); 55 | } 56 | 57 | return sources; 58 | }; 59 | 60 | export const rescriptFileFilterRegexp = new RegExp(/(\%relay\()/g); 61 | export const rescriptGraphQLTagsRegexp = new RegExp( 62 | /(?<=\%relay\([\s]*`)[\s\S.]+?(?=`[\s]*\))/g 63 | ); 64 | 65 | export const extractGraphQLSourceFromReScript = makeExtractTagsFromSource( 66 | rescriptGraphQLTagsRegexp 67 | ); 68 | 69 | const cache = new LRUCache(5); 70 | 71 | export function extractGraphQLSources( 72 | document: string, 73 | useCache = true 74 | ): GraphQLSource[] | null { 75 | if (useCache) { 76 | const cached = cache.get(document); 77 | 78 | if (cached != null) { 79 | return cached; 80 | } 81 | 82 | const res = extractGraphQLSourceFromReScript(document); 83 | cache.set(document, res); 84 | return res; 85 | } else { 86 | return extractGraphQLSourceFromReScript(document); 87 | } 88 | } 89 | 90 | export function extractSelectedOperation( 91 | document: string, 92 | selection: { 93 | line: number; 94 | character: number; 95 | } 96 | ): GraphQLSource | null { 97 | const sources = extractGraphQLSources(document); 98 | 99 | if (!sources || sources.length < 1) { 100 | return null; 101 | } 102 | 103 | let targetSource: GraphQLSource | null = null; 104 | 105 | if (sources[0].type === "FULL_DOCUMENT") { 106 | targetSource = sources[0]; 107 | } else { 108 | // A tag must be focused 109 | for (let i = 0; i <= sources.length - 1; i += 1) { 110 | const t = sources[i]; 111 | 112 | if ( 113 | t.type === "TAG" && 114 | selection.line >= t.start.line && 115 | selection.line <= t.end.line 116 | ) { 117 | targetSource = t; 118 | } 119 | } 120 | } 121 | 122 | return targetSource; 123 | } 124 | 125 | export function getSelectedGraphQLOperation( 126 | doc: string, 127 | pos: { line: number; character: number } 128 | ): GraphQLSourceFromTag | null { 129 | const selectedOperation = extractSelectedOperation(doc, { 130 | line: pos.line, 131 | character: pos.character, 132 | }); 133 | 134 | if (selectedOperation && selectedOperation.type === "TAG") { 135 | return selectedOperation; 136 | } 137 | 138 | return null; 139 | } 140 | -------------------------------------------------------------------------------- /src/graphqlConfig.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLConfig, loadConfig, loadConfigSync } from "graphql-config"; 2 | import { RelayDirectivesExtension } from "./configUtils"; 3 | import * as c from "cosmiconfig"; 4 | 5 | const configLoader = c.cosmiconfigSync("relay", { 6 | searchPlaces: [ 7 | "package.json", 8 | "relay.config.js", 9 | "relay.json", 10 | "relay.config.cjs", 11 | "relay.config.json", 12 | ], 13 | }); 14 | 15 | const makeLoadConfig = (workspaceBaseDir: string | undefined) => { 16 | const res = configLoader.search(workspaceBaseDir); 17 | 18 | if (res == null) { 19 | throw new Error("Did not find config."); 20 | } 21 | 22 | return { 23 | filepath: res.filepath, 24 | configName: "relay", 25 | extensions: [RelayDirectivesExtension], 26 | rootDir: workspaceBaseDir, 27 | }; 28 | }; 29 | 30 | export async function createGraphQLConfig( 31 | workspaceBaseDir: string | undefined 32 | ): Promise { 33 | const config = await loadConfig(makeLoadConfig(workspaceBaseDir)); 34 | 35 | if (!config) { 36 | return; 37 | } 38 | 39 | return config; 40 | } 41 | 42 | export function createGraphQLConfigSync( 43 | workspaceBaseDir: string | undefined 44 | ): GraphQLConfig | undefined { 45 | const config = loadConfigSync(makeLoadConfig(workspaceBaseDir)); 46 | 47 | if (!config) { 48 | return; 49 | } 50 | 51 | return config; 52 | } 53 | -------------------------------------------------------------------------------- /src/graphqlUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Source, 3 | getLocation, 4 | GraphQLObjectType, 5 | GraphQLField, 6 | ValueNode, 7 | ArgumentNode, 8 | SelectionNode, 9 | FieldNode, 10 | ObjectFieldNode, 11 | VariableDefinitionNode, 12 | OperationDefinitionNode, 13 | DirectiveNode, 14 | Location, 15 | FragmentDefinitionNode, 16 | FragmentSpreadNode, 17 | parse, 18 | visit, 19 | GraphQLInterfaceType, 20 | print, 21 | getNamedType, 22 | GraphQLUnionType, 23 | VariableNode, 24 | OperationTypeNode, 25 | GraphQLScalarType, 26 | GraphQLEnumType, 27 | GraphQLNamedType, 28 | SourceLocation, 29 | } from "graphql"; 30 | 31 | import { Position, window } from "vscode"; 32 | import * as path from "path"; 33 | import { loadFullSchema } from "./loadSchema"; 34 | import { prettify } from "./extensionUtils"; 35 | import { State } from "graphql-language-service-parser"; 36 | import { quickPickFromSchema } from "./addGraphQLComponent"; 37 | import { GraphQLSourceFromTag } from "./extensionTypes"; 38 | import { 39 | makeSelectionSet, 40 | makeFirstFieldSelection, 41 | makeFieldSelection, 42 | getFirstField, 43 | } from "./graphqlUtilsNoVscode"; 44 | 45 | export interface NodeWithLoc { 46 | loc?: Location | undefined; 47 | } 48 | 49 | export type NodesWithDirectives = 50 | | FragmentDefinitionNode 51 | | FieldNode 52 | | FragmentSpreadNode 53 | | OperationDefinitionNode; 54 | 55 | export function getStateName(state: State): string | undefined { 56 | switch (state.kind) { 57 | case "OperationDefinition": 58 | case "FragmentDefinition": 59 | case "AliasedField": 60 | case "Field": 61 | return state.name ? state.name : undefined; 62 | } 63 | } 64 | 65 | export function runOnNodeAtPos( 66 | source: Source, 67 | node: T, 68 | pos: Position, 69 | fn: (node: T) => T | undefined 70 | ) { 71 | const { loc } = node; 72 | 73 | if (!loc) { 74 | return; 75 | } 76 | 77 | const nodeLoc = getLocation(source, loc.start); 78 | 79 | if (nodeLoc.line === pos.line + 1) { 80 | return fn(node); 81 | } 82 | } 83 | 84 | export function makeArgument(name: string, value: ValueNode): ArgumentNode { 85 | return { 86 | kind: "Argument", 87 | name: { 88 | kind: "Name", 89 | value: name, 90 | }, 91 | value, 92 | }; 93 | } 94 | 95 | export function makeArgumentDefinitionVariable( 96 | name: string, 97 | type: string, 98 | defaultValue?: string | undefined 99 | ): ArgumentNode { 100 | const fields: ObjectFieldNode[] = [ 101 | { 102 | kind: "ObjectField", 103 | name: { 104 | kind: "Name", 105 | value: "type", 106 | }, 107 | value: { 108 | kind: "StringValue", 109 | value: type, 110 | }, 111 | }, 112 | ]; 113 | 114 | if (defaultValue != null) { 115 | fields.push({ 116 | kind: "ObjectField", 117 | name: { 118 | kind: "Name", 119 | value: "defaultValue", 120 | }, 121 | value: { 122 | kind: "IntValue", 123 | value: defaultValue, 124 | }, 125 | }); 126 | } 127 | 128 | return { 129 | kind: "Argument", 130 | name: { 131 | kind: "Name", 132 | value: name, 133 | }, 134 | value: { 135 | kind: "ObjectValue", 136 | fields, 137 | }, 138 | }; 139 | } 140 | 141 | export function makeVariableDefinitionNode( 142 | name: string, 143 | value: string 144 | ): VariableDefinitionNode | undefined { 145 | const ast = parse(`mutation($${name}: ${value}) { id }`); 146 | const firstDef = ast.definitions[0]; 147 | 148 | if ( 149 | firstDef && 150 | firstDef.kind === "OperationDefinition" && 151 | firstDef.variableDefinitions 152 | ) { 153 | return firstDef.variableDefinitions.find( 154 | (v) => v.variable.name.value === name 155 | ); 156 | } 157 | } 158 | 159 | export function makeVariableNode(name: string): VariableNode { 160 | return { 161 | kind: "Variable", 162 | name: { 163 | kind: "Name", 164 | value: name, 165 | }, 166 | }; 167 | } 168 | 169 | export function findPath(state: State): string[] { 170 | const rootName = getStateName(state); 171 | 172 | const path: string[] = rootName ? [rootName] : []; 173 | 174 | let prevState = state.prevState; 175 | 176 | while (prevState) { 177 | const name = getStateName(prevState); 178 | if (name) { 179 | path.push(name); 180 | } 181 | 182 | prevState = prevState.prevState; 183 | } 184 | 185 | return path; 186 | } 187 | 188 | export function nodeHasDirective( 189 | node: NodesWithDirectives, 190 | name: string, 191 | hasArgs?: (args: readonly ArgumentNode[]) => boolean 192 | ): boolean { 193 | const directive = node.directives 194 | ? node.directives.find((d) => d.name.value === name) 195 | : undefined; 196 | 197 | if (!directive) { 198 | return false; 199 | } 200 | 201 | if (hasArgs) { 202 | return directive.arguments ? hasArgs(directive.arguments) : false; 203 | } 204 | 205 | return true; 206 | } 207 | 208 | export function nodeHasVariable( 209 | node: OperationDefinitionNode, 210 | name: string 211 | ): boolean { 212 | return node.variableDefinitions 213 | ? !!node.variableDefinitions.find((v) => v.variable.name.value === name) 214 | : false; 215 | } 216 | 217 | export function addDirectiveToNode( 218 | node: T, 219 | name: string, 220 | args: ArgumentNode[] 221 | ): T { 222 | let directives = node.directives || []; 223 | 224 | const existingDirectiveNode: DirectiveNode | undefined = directives.find( 225 | (d) => d.name.value === name 226 | ); 227 | 228 | let directiveNode: DirectiveNode = existingDirectiveNode || { 229 | kind: "Directive", 230 | name: { 231 | kind: "Name", 232 | value: name, 233 | }, 234 | arguments: args, 235 | }; 236 | 237 | if (existingDirectiveNode) { 238 | directiveNode = { 239 | ...directiveNode, 240 | arguments: [...(existingDirectiveNode.arguments || []), ...args].reduce( 241 | (acc: ArgumentNode[], curr) => { 242 | const asNewArg = args.find((a) => a.name === curr.name); 243 | 244 | if (!acc.find((a) => a.name === curr.name)) { 245 | acc.push(asNewArg ? asNewArg : curr); 246 | } 247 | 248 | return acc; 249 | }, 250 | [] 251 | ), 252 | }; 253 | } 254 | 255 | return { 256 | ...node, 257 | directives: [ 258 | ...directives.filter((d) => d.name !== directiveNode.name), 259 | directiveNode, 260 | ], 261 | }; 262 | } 263 | 264 | export async function makeFragment( 265 | fragmentName: string, 266 | onTypeName: string, 267 | selections?: SelectionNode[] 268 | ): Promise { 269 | const schema = await loadFullSchema(); 270 | 271 | if (!schema) { 272 | throw new Error("Could not get schema."); 273 | } 274 | 275 | const onType = schema.getType(onTypeName); 276 | 277 | if ( 278 | onType && 279 | (onType instanceof GraphQLObjectType || 280 | onType instanceof GraphQLInterfaceType || 281 | onType instanceof GraphQLUnionType) 282 | ) { 283 | const newFragment = prettify( 284 | print( 285 | visit( 286 | parse(`fragment ${fragmentName} on ${onTypeName} { __typename }`), 287 | { 288 | FragmentDefinition(node) { 289 | const newNode: FragmentDefinitionNode = { 290 | ...node, 291 | selectionSet: makeSelectionSet( 292 | !selections || selections.length === 0 293 | ? [...makeFirstFieldSelection(onType)] 294 | : selections 295 | ), 296 | }; 297 | 298 | return newNode; 299 | }, 300 | } 301 | ) 302 | ) 303 | ); 304 | 305 | return newFragment; 306 | } 307 | 308 | throw new Error("Could not build fragment..."); 309 | } 310 | 311 | interface MakeOperationConfig { 312 | operationType: OperationTypeNode; 313 | operationName: string; 314 | rootField: GraphQLField< 315 | any, 316 | any, 317 | { 318 | [key: string]: any; 319 | } 320 | >; 321 | onType?: string; 322 | skipAddingFieldSelections?: boolean; 323 | creator?: (node: OperationDefinitionNode) => OperationDefinitionNode; 324 | } 325 | 326 | export async function makeOperation({ 327 | operationType, 328 | operationName, 329 | rootField, 330 | onType, 331 | creator, 332 | }: MakeOperationConfig): Promise { 333 | return prettify( 334 | print( 335 | visit(parse(`${operationType} ${operationName} { __typename }`), { 336 | OperationDefinition(node) { 337 | if (creator) { 338 | return creator(node); 339 | } 340 | 341 | const rootFieldType = getNamedType(rootField.type); 342 | 343 | const requiredArgs = rootField.args.filter((a) => 344 | a.type.toString().endsWith("!") 345 | ); 346 | 347 | const firstField = 348 | rootFieldType instanceof GraphQLObjectType 349 | ? getFirstField(rootFieldType, operationType) 350 | : null; 351 | 352 | const newNode: OperationDefinitionNode = { 353 | ...node, 354 | variableDefinitions: requiredArgs.reduce( 355 | (acc: VariableDefinitionNode[], a) => { 356 | const v = makeVariableDefinitionNode(a.name, a.type.toString()); 357 | if (v) { 358 | acc.push(v); 359 | } 360 | 361 | return acc; 362 | }, 363 | [] 364 | ), 365 | selectionSet: makeSelectionSet([ 366 | makeFieldSelection( 367 | rootField.name, 368 | rootFieldType instanceof GraphQLUnionType || 369 | rootFieldType instanceof GraphQLInterfaceType 370 | ? onType 371 | ? [ 372 | makeFieldSelection("__typename"), 373 | { 374 | kind: "InlineFragment", 375 | typeCondition: { 376 | kind: "NamedType", 377 | name: { kind: "Name", value: onType }, 378 | }, 379 | selectionSet: { 380 | kind: "SelectionSet", 381 | selections: [makeFieldSelection("id")], 382 | }, 383 | }, 384 | ] 385 | : [makeFieldSelection("__typename")] 386 | : rootFieldType instanceof GraphQLScalarType || 387 | rootFieldType instanceof GraphQLEnumType 388 | ? [] 389 | : rootFieldType instanceof GraphQLObjectType 390 | ? [ 391 | firstField && firstField.type instanceof GraphQLObjectType 392 | ? makeFieldSelection( 393 | firstField.name, 394 | makeFirstFieldSelection( 395 | firstField.type 396 | ).map((f) => ({ kind: "Field", name: f.name })) 397 | ) 398 | : makeFieldSelection(getFirstField(rootFieldType).name), 399 | ] 400 | : [], 401 | requiredArgs.map((a) => 402 | makeArgument(a.name, makeVariableNode(a.name)) 403 | ) 404 | ), 405 | ]), 406 | }; 407 | 408 | return newNode; 409 | }, 410 | }) 411 | ) 412 | ); 413 | } 414 | 415 | export const makeConnectionsVariable = ( 416 | op: OperationDefinitionNode 417 | ): VariableDefinitionNode[] => { 418 | return [ 419 | ...(op.variableDefinitions ?? []), 420 | { 421 | kind: "VariableDefinition", 422 | variable: { 423 | kind: "Variable", 424 | name: { kind: "Name", value: "connections" }, 425 | }, 426 | type: { 427 | kind: "NonNullType", 428 | type: { 429 | kind: "ListType", 430 | type: { 431 | kind: "NonNullType", 432 | type: { 433 | kind: "NamedType", 434 | name: { kind: "Name", value: "ID" }, 435 | }, 436 | }, 437 | }, 438 | }, 439 | }, 440 | ]; 441 | }; 442 | 443 | export async function pickTypeForFragment(): Promise { 444 | const { result } = quickPickFromSchema("Select type of the fragment", (s) => 445 | Object.values(s.getTypeMap()).reduce( 446 | (acc: string[], curr: GraphQLNamedType) => { 447 | if ( 448 | (curr instanceof GraphQLObjectType || 449 | curr instanceof GraphQLInterfaceType || 450 | curr instanceof GraphQLUnionType) && 451 | !curr.name.startsWith("__") 452 | ) { 453 | acc.push(curr.name); 454 | } 455 | 456 | return acc; 457 | }, 458 | [] 459 | ) 460 | ); 461 | 462 | return await result; 463 | } 464 | 465 | type GetFragmentComponentTextConfig = { 466 | moduleName: string; 467 | fragmentText: string; 468 | propName: string; 469 | }; 470 | 471 | export function getFragmentComponentText({ 472 | moduleName, 473 | fragmentText, 474 | propName, 475 | }: GetFragmentComponentTextConfig) { 476 | return `module ${moduleName} = %relay(\` 477 | ${fragmentText 478 | .split("\n") 479 | .map((s) => ` ${s}`) 480 | .join("\n")} 481 | \`) 482 | 483 | @react.component 484 | let make = (~${propName}) => { 485 | let ${propName} = ${moduleName}.use(${propName}) 486 | 487 | React.null 488 | }`; 489 | } 490 | 491 | export function getNewFilePath(newComponentName: string) { 492 | const editor = window.activeTextEditor; 493 | 494 | if (editor == null) { 495 | throw new Error("Could not find active editor."); 496 | } 497 | 498 | const currentFilePath = editor.document.uri.path; 499 | const thisFileName = path.basename(currentFilePath); 500 | 501 | const newFilePath = editor.document.uri.with({ 502 | path: `${currentFilePath.slice( 503 | 0, 504 | currentFilePath.length - thisFileName.length 505 | )}${newComponentName}.res`, 506 | }); 507 | 508 | return newFilePath; 509 | } 510 | 511 | const lineCharToPos = ({ 512 | line, 513 | character, 514 | }: { 515 | line: number; 516 | character: number; 517 | }) => new Position(line, character); 518 | 519 | export const getStartPosFromTag = (tag: GraphQLSourceFromTag) => 520 | lineCharToPos(tag.start); 521 | 522 | export const getEnPosFromTag = (tag: GraphQLSourceFromTag) => 523 | lineCharToPos(tag.start); 524 | 525 | export const getAdjustedPosition = ( 526 | tag: GraphQLSourceFromTag, 527 | sourceLoc?: SourceLocation | null 528 | ) => { 529 | if (sourceLoc == null) { 530 | return new Position(tag.start.line, tag.start.character); 531 | } 532 | 533 | return new Position(sourceLoc.line + tag.start.line, sourceLoc.column); 534 | }; 535 | -------------------------------------------------------------------------------- /src/graphqlUtilsNoVscode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SelectionNode, 3 | SelectionSetNode, 4 | ArgumentNode, 5 | FieldNode, 6 | GraphQLObjectType, 7 | GraphQLInterfaceType, 8 | GraphQLUnionType, 9 | getNamedType, 10 | GraphQLField, 11 | OperationTypeNode, 12 | } from "graphql"; 13 | 14 | export function getFirstField( 15 | obj: GraphQLObjectType | GraphQLInterfaceType, 16 | type?: OperationTypeNode 17 | ): GraphQLField { 18 | const fields = Object.values(obj.getFields()); 19 | 20 | if (type === "mutation") { 21 | const firstRealField = fields.find( 22 | (v) => v.type instanceof GraphQLObjectType 23 | ); 24 | 25 | if (firstRealField) { 26 | return firstRealField; 27 | } 28 | } 29 | 30 | const hasIdField = fields.find((v) => v.name === "id"); 31 | const firstField = hasIdField ? hasIdField : fields[0]; 32 | 33 | return firstField; 34 | } 35 | 36 | export function makeSelectionSet( 37 | selections: SelectionNode[] 38 | ): SelectionSetNode { 39 | return { 40 | kind: "SelectionSet", 41 | selections, 42 | }; 43 | } 44 | 45 | export function makeFieldSelection( 46 | name: string, 47 | selections?: SelectionNode[], 48 | args?: ArgumentNode[] 49 | ): FieldNode { 50 | return { 51 | kind: "Field", 52 | name: { 53 | kind: "Name", 54 | value: name, 55 | }, 56 | selectionSet: selections != null ? makeSelectionSet(selections) : undefined, 57 | arguments: args, 58 | }; 59 | } 60 | 61 | export function makeFirstFieldSelection( 62 | type: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType 63 | ): FieldNode[] { 64 | if (type instanceof GraphQLUnionType) { 65 | return [makeFieldSelection("__typename")]; 66 | } 67 | 68 | const firstField = getFirstField(type); 69 | const fieldType = getNamedType(firstField.type); 70 | 71 | const fieldNodes: FieldNode[] = []; 72 | 73 | if ( 74 | fieldType instanceof GraphQLObjectType || 75 | fieldType instanceof GraphQLInterfaceType 76 | ) { 77 | if (fieldType instanceof GraphQLInterfaceType) { 78 | // Always include __typename for interfaces 79 | fieldNodes.push(makeFieldSelection("__typename")); 80 | } 81 | 82 | // Include sub selections automatically 83 | fieldNodes.push( 84 | makeFieldSelection(firstField.name, [makeFieldSelection("__typename")]) 85 | ); 86 | 87 | return fieldNodes; 88 | } 89 | 90 | return [makeFieldSelection(firstField.name)]; 91 | } 92 | -------------------------------------------------------------------------------- /src/loadSchema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from "graphql"; 2 | import { workspace, window, Uri } from "vscode"; 3 | import { createGraphQLConfig } from "./graphqlConfig"; 4 | import { GraphQLConfig } from "graphql-config"; 5 | import * as path from "path"; 6 | import { hasHighEnoughReScriptRelayVersion } from "./utilsNoVsCode"; 7 | interface SchemaCache { 8 | config: GraphQLConfig; 9 | schema: GraphQLSchema; 10 | } 11 | 12 | interface WorkspaceSchemaCache { 13 | [id: string]: SchemaCache | undefined; 14 | } 15 | 16 | const cache: WorkspaceSchemaCache = {}; 17 | 18 | export const cacheControl = { 19 | async refresh(workspaceBaseDir: string) { 20 | const config = await createGraphQLConfig(workspaceBaseDir); 21 | 22 | if (!config) { 23 | return false; 24 | } 25 | 26 | const entry: SchemaCache = { 27 | config, 28 | schema: await config.getProject().getSchema(), 29 | }; 30 | 31 | cache[workspaceBaseDir] = entry; 32 | loadSchemaCachePromise = undefined; 33 | 34 | return true; 35 | }, 36 | async get(workspaceBaseDir: string) { 37 | if (!cache[workspaceBaseDir]) { 38 | await this.refresh(workspaceBaseDir); 39 | } 40 | 41 | return cache[workspaceBaseDir]; 42 | }, 43 | remove(workspaceBaseDir: string) { 44 | cache[workspaceBaseDir] = undefined; 45 | }, 46 | }; 47 | 48 | export function getCurrentWorkspaceRoot(): string | undefined { 49 | if (workspace.workspaceFolders) { 50 | const workspaceFolder = workspace.workspaceFolders[0].uri; 51 | const rootDir = workspace 52 | .getConfiguration("graphql-config.load") 53 | .get("rootDir"); 54 | if (rootDir && typeof rootDir == "string") { 55 | return Uri.joinPath(workspaceFolder, rootDir).fsPath; 56 | } else return workspaceFolder.fsPath; 57 | } 58 | } 59 | 60 | let loadSchemaCachePromise: Promise | undefined; 61 | 62 | export function getSchemaCacheForWorkspace( 63 | workspaceBaseDir: string 64 | ): Promise { 65 | if (loadSchemaCachePromise) { 66 | return loadSchemaCachePromise; 67 | } 68 | 69 | loadSchemaCachePromise = new Promise(async (resolve) => { 70 | const fromCache = cache[workspaceBaseDir]; 71 | 72 | if (fromCache) { 73 | loadSchemaCachePromise = undefined; 74 | return resolve(fromCache); 75 | } 76 | 77 | let schema: GraphQLSchema | undefined; 78 | let config: GraphQLConfig | undefined; 79 | 80 | try { 81 | config = await createGraphQLConfig(workspaceBaseDir); 82 | } catch { 83 | config = undefined; 84 | } 85 | 86 | if (!config) { 87 | return; 88 | } 89 | 90 | schema = await config.getProject().getSchema(); 91 | 92 | if (!config || !schema) { 93 | loadSchemaCachePromise = undefined; 94 | return; 95 | } 96 | 97 | const entry: SchemaCache = { 98 | config, 99 | schema, 100 | }; 101 | 102 | cache[workspaceBaseDir] = entry; 103 | loadSchemaCachePromise = undefined; 104 | 105 | resolve(entry); 106 | }); 107 | 108 | return loadSchemaCachePromise; 109 | } 110 | 111 | export async function loadFullSchema(): Promise { 112 | const workspaceRoot = getCurrentWorkspaceRoot(); 113 | 114 | if (!workspaceRoot) { 115 | return; 116 | } 117 | 118 | const cacheEntry = await getSchemaCacheForWorkspace(workspaceRoot); 119 | return cacheEntry ? cacheEntry.schema : undefined; 120 | } 121 | 122 | export async function loadGraphQLConfig(): Promise { 123 | const workspaceRoot = getCurrentWorkspaceRoot(); 124 | 125 | if (!workspaceRoot) { 126 | return; 127 | } 128 | 129 | const cacheEntry = await getSchemaCacheForWorkspace(workspaceRoot); 130 | return cacheEntry ? cacheEntry.config : undefined; 131 | } 132 | 133 | export type RelayConfig = { 134 | src: string; 135 | schema: string; 136 | artifactDirectory: string; 137 | }; 138 | 139 | let loadedRelayConfig: RelayConfig | null = null; 140 | 141 | export async function loadRelayConfig( 142 | forceReload?: boolean 143 | ): Promise { 144 | if (loadedRelayConfig != null && !forceReload) { 145 | return loadedRelayConfig; 146 | } 147 | 148 | const config = await loadGraphQLConfig(); 149 | if (config) { 150 | let relayConfig: RelayConfig | undefined; 151 | try { 152 | const configFilePath = config.getProject().filepath; 153 | const rawRelayConfig = require(config.getProject().filepath); 154 | 155 | relayConfig = { 156 | src: path.resolve(path.dirname(configFilePath), rawRelayConfig.src), 157 | schema: path.resolve( 158 | path.dirname(configFilePath), 159 | rawRelayConfig.schema 160 | ), 161 | artifactDirectory: path.resolve( 162 | path.dirname(configFilePath), 163 | rawRelayConfig.artifactDirectory 164 | ), 165 | }; 166 | } catch (e) { 167 | return; 168 | } 169 | 170 | loadedRelayConfig = relayConfig; 171 | return loadedRelayConfig; 172 | } 173 | } 174 | 175 | export async function isReScriptRelayProject(): Promise<{ 176 | type: "rescript-relay" | "reason-relay"; 177 | } | null> { 178 | const [config, relayConfig] = await Promise.all([ 179 | loadGraphQLConfig(), 180 | loadRelayConfig(), 181 | ]); 182 | 183 | if (config && relayConfig) { 184 | try { 185 | const configFilePath = config.getProject().filepath; 186 | const pkgJson = require(path.join( 187 | path.dirname(configFilePath), 188 | "package.json" 189 | )); 190 | 191 | if (pkgJson) { 192 | const deps = [ 193 | ...Object.keys(pkgJson.dependencies || {}), 194 | ...Object.keys(pkgJson.devDependencies || {}), 195 | ...Object.keys(pkgJson.peerDependencies || {}), 196 | ]; 197 | for (const d of deps) { 198 | if (d === "rescript-relay") { 199 | return { type: "rescript-relay" }; 200 | } 201 | 202 | if (d === "reason-relay") { 203 | const version: string = { 204 | ...pkgJson.dependencies, 205 | ...pkgJson.devDependencies, 206 | ...pkgJson.peerDependencies, 207 | }["reason-relay"]; 208 | 209 | if (hasHighEnoughReScriptRelayVersion(version)) { 210 | return { type: "reason-relay" }; 211 | } else { 212 | window.showWarningMessage( 213 | "`vscode-rescript-relay` only supports ReasonRelay/ReScriptRelay versions >= 0.13.0." 214 | ); 215 | return null; 216 | } 217 | } 218 | } 219 | } 220 | } catch (error) { 221 | console.error(error); 222 | } 223 | } 224 | 225 | return null; 226 | } 227 | -------------------------------------------------------------------------------- /src/lspUtils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { LanguageClient, RequestType } from "vscode-languageclient/node"; 3 | 4 | export interface SingleRoute { 5 | sourceFilePath: string; 6 | routeName: string; 7 | loc: { line: number; character: number }; 8 | routeRendererFilePath: string; 9 | } 10 | 11 | const LSP_CUSTOM_REQUESTS = { 12 | routesForFile: new RequestType( 13 | "textDocument/rescriptRelayRouterRoutes" 14 | ), 15 | matchUrl: new RequestType( 16 | "textDocument/rescriptRelayRouterRoutesMatchingUrl" 17 | ), 18 | }; 19 | 20 | export const routerLspRoutesForFile = ( 21 | client: LanguageClient, 22 | fileUri: string 23 | ) => { 24 | return client.sendRequest( 25 | LSP_CUSTOM_REQUESTS.routesForFile, 26 | path.basename(fileUri, ".res") 27 | ); 28 | }; 29 | 30 | export const routerLspMatchUrl = (client: LanguageClient, url: string) => { 31 | return client.sendRequest(LSP_CUSTOM_REQUESTS.matchUrl, url); 32 | }; 33 | -------------------------------------------------------------------------------- /src/relayDirectives.ts: -------------------------------------------------------------------------------- 1 | import { parse, DirectiveDefinitionNode } from "graphql"; 2 | 3 | const directives = parse(` 4 | directive @relay_test_operation on QUERY | MUTATION | SUBSCRIPTION 5 | 6 | directive @inline on FRAGMENT_DEFINITION 7 | 8 | directive @raw_response_type on QUERY | MUTATION | SUBSCRIPTION 9 | 10 | #directive @relay_early_flush on QUERY 11 | 12 | directive @refetchable(queryName: String!) on FRAGMENT_DEFINITION 13 | 14 | #directive @preloadable on QUERY 15 | 16 | directive @relay( 17 | mask: Boolean 18 | plural: Boolean 19 | ) on FRAGMENT_DEFINITION | FRAGMENT_SPREAD 20 | 21 | # MatchTransform 22 | directive @match(key: String) on FIELD 23 | 24 | directive @module(name: String!) on FRAGMENT_SPREAD 25 | 26 | # ConnectionTransform 27 | directive @connection( 28 | key: String! 29 | filters: [String] 30 | handler: String 31 | dynamicKey_UNSTABLE: String 32 | ) on FIELD 33 | 34 | directive @stream_connection( 35 | key: String! 36 | filters: [String] 37 | handler: String 38 | label: String! 39 | initial_count: Int! 40 | if: Boolean = true 41 | use_customized_batch: Boolean = false 42 | dynamicKey_UNSTABLE: String 43 | ) on FIELD 44 | 45 | # TODO: Add RequiredFieldAction 46 | 47 | # DeclarativeConnection 48 | directive @deleteRecord on FIELD 49 | directive @deleteEdge(connections: [ID!]!) on FIELD 50 | directive @appendEdge(connections: [ID!]!) on FIELD 51 | directive @prependEdge(connections: [ID!]!) on FIELD 52 | directive @appendNode(connections: [ID!]!, edgeTypeName: String!) on FIELD 53 | directive @prependNode(connections: [ID!]!, edgeTypeName: String!) on FIELD 54 | `); 55 | 56 | export const directiveNodes: DirectiveDefinitionNode[] = directives.definitions.reduce( 57 | (acc: DirectiveDefinitionNode[], curr) => { 58 | if (curr.kind === "DirectiveDefinition") { 59 | acc.push(curr); 60 | } 61 | 62 | return acc; 63 | }, 64 | [] 65 | ); 66 | -------------------------------------------------------------------------------- /src/schemaLoaders.ts: -------------------------------------------------------------------------------- 1 | import { SchemaLoader } from "./extensionTypes"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | const getSchemaType = (schemaPath: string): "json" | "sdl" | null => { 6 | const schemaExtName = path.extname(schemaPath); 7 | 8 | return schemaExtName === ".graphql" 9 | ? "sdl" 10 | : schemaExtName === ".json" 11 | ? "json" 12 | : null; 13 | }; 14 | 15 | /** 16 | * This file defines schema loaders, which are simply functions that 17 | * try to find the appropriate schema file in various ways. 18 | */ 19 | export const rawSchemaFileLoader: SchemaLoader = async ( 20 | rootPath: string, 21 | filesInRoot: Array 22 | ) => { 23 | const schemaFile = filesInRoot.find( 24 | (f) => f === "schema.graphql" || f === "schema.json" 25 | ); 26 | 27 | if (!schemaFile) { 28 | return null; 29 | } 30 | 31 | const schemaType = getSchemaType(schemaFile); 32 | 33 | return schemaType 34 | ? { 35 | type: schemaType, 36 | content: fs.readFileSync(path.join(rootPath, schemaFile), "utf8"), 37 | } 38 | : null; 39 | }; 40 | 41 | export const loaders: Array = [rawSchemaFileLoader]; 42 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from "graphql-language-service-server"; 2 | import { Range, Position } from "graphql-language-service-utils"; 3 | import { CachedContent } from "graphql-language-service-types"; 4 | import { extractGraphQLSources } from "./findGraphQLSources"; 5 | import { createGraphQLConfigSync } from "./graphqlConfig"; 6 | 7 | (async () => { 8 | try { 9 | await startServer({ 10 | method: "node", 11 | config: createGraphQLConfigSync(process.env.ROOT_DIR), 12 | parser: (doc: string) => { 13 | const sources = extractGraphQLSources(doc, false); 14 | 15 | return (sources || []).reduce((acc: CachedContent[], curr) => { 16 | if (curr.type === "TAG") { 17 | acc.push({ 18 | query: curr.content, 19 | range: new Range( 20 | new Position(curr.start.line, curr.start.character), 21 | new Position(curr.end.line, curr.end.character) 22 | ), 23 | }); 24 | } 25 | 26 | return acc; 27 | }, []); 28 | }, 29 | }); 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | })(); 34 | -------------------------------------------------------------------------------- /src/testfixture/graphqlSources.res: -------------------------------------------------------------------------------- 1 | module TodoFragment = %relay( 2 | ` 3 | fragment SingleTodo_todoItem on TodoItem { 4 | id 5 | text 6 | completed 7 | } 8 | ` 9 | ) 10 | 11 | module DeleteMutation = %relay( 12 | ` 13 | mutation SingleTodoDeleteMutation( 14 | $input: DeleteTodoItemInput! 15 | $connections: [ID!]! 16 | ) @raw_response_type { 17 | deleteTodoItem(input: $input) { 18 | deletedTodoItemId @deleteEdge(connections: $connections) 19 | } 20 | } 21 | ` 22 | ) 23 | 24 | module UpdateMutation = %relay( 25 | ` 26 | mutation SingleTodoUpdateMutation($input: UpdateTodoItemInput!) { 27 | updateTodoItem(input: $input) { 28 | updatedTodoItem { 29 | id 30 | text 31 | completed 32 | } 33 | } 34 | } 35 | ` 36 | ) 37 | 38 | @react.component 39 | let make = (~checked, ~todoItem as todoItemRef, ~todosConnectionId) => { 40 | let todoItem = TodoFragment.use(todoItemRef) 41 | 42 |
  • "completed" 45 | | Some(false) 46 | | None => "" 47 | }}> 48 |
    49 | 81 |
    82 | 84 | DeleteMutation.commitMutation( 85 | ~environment=RelayEnv.environment, 86 | ~variables={ 87 | input: { 88 | clientMutationId: None, 89 | id: todoItem.id, 90 | }, 91 | connections: [todosConnectionId->RescriptRelay.dataIdToString], 92 | }, 93 | ~optimisticResponse={ 94 | deleteTodoItem: Some({deletedTodoItemId: Some(todoItem.id)}), 95 | }, 96 | (), 97 | ) |> ignore} 98 | role="button" 99 | className="remove mdi mdi-close-circle-outline" 100 | /> 101 |
  • 102 | } 103 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from "vscode"; 2 | 3 | export function getPreferredFragmentPropName(onType: string): string { 4 | const result = 5 | workspace.getConfiguration("rescript-relay").get("preferShortNames") === 6 | true 7 | ? onType.split(/(?=[A-Z])|_/g).pop() ?? onType 8 | : onType; 9 | 10 | // Handle common ReScript keywords 11 | if (result.toLowerCase().endsWith("type")) { 12 | return result.slice(0, result.length - 1); 13 | } 14 | 15 | return result; 16 | } 17 | 18 | export const featureEnabled = (key: string) => 19 | workspace.getConfiguration("rescript-relay").get(key) === true; 20 | -------------------------------------------------------------------------------- /src/utilsNoVsCode.ts: -------------------------------------------------------------------------------- 1 | import semver from "semver"; 2 | 3 | export function hasHighEnoughReScriptRelayVersion(version: string): boolean { 4 | return semver.satisfies(version.replace(/[\^\~]/g, ""), ">=0.13.0"); 5 | } 6 | -------------------------------------------------------------------------------- /syntaxes/graphql.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GraphQL", 3 | "scopeName": "source.graphql", 4 | "fileTypes": ["graphql", "graphqls", "gql", "graphcool"], 5 | "patterns": [ 6 | { 7 | "include": "#graphql" 8 | } 9 | ], 10 | "repository": { 11 | "graphql": { 12 | "patterns": [ 13 | { 14 | "include": "#graphql-fragment-definition" 15 | }, 16 | { 17 | "include": "#graphql-type-interface" 18 | }, 19 | { 20 | "include": "#graphql-enum" 21 | }, 22 | { 23 | "include": "#graphql-scalar" 24 | }, 25 | { 26 | "include": "#graphql-union" 27 | }, 28 | { 29 | "include": "#graphql-schema" 30 | }, 31 | { 32 | "include": "#graphql-operation-def" 33 | }, 34 | { 35 | "include": "#graphql-comment" 36 | }, 37 | { 38 | "include": "#literal-quasi-embedded" 39 | } 40 | ] 41 | }, 42 | "graphql-operation-def": { 43 | "patterns": [ 44 | { 45 | "include": "#graphql-query-mutation-subscription" 46 | }, 47 | { 48 | "include": "#graphql-directive-definition" 49 | }, 50 | { 51 | "include": "#graphql-name" 52 | }, 53 | { 54 | "include": "#graphql-variable-definitions" 55 | }, 56 | { 57 | "include": "#graphql-directive" 58 | }, 59 | { 60 | "include": "#graphql-selection-set" 61 | } 62 | ] 63 | }, 64 | "graphql-fragment-definition": { 65 | "name": "meta.fragment.graphql", 66 | "begin": "\\s*(?:(\\bfragment\\b)\\s*([_A-Za-z][_0-9A-Za-z]*)?\\s*(?:(\\bon\\b)\\s*([_A-Za-z][_0-9A-Za-z]*)))", 67 | "end": "(?<=})", 68 | "captures": { 69 | "1": { 70 | "name": "keyword.fragment.graphql" 71 | }, 72 | "2": { 73 | "name": "entity.name.fragment.graphql" 74 | }, 75 | "3": { 76 | "name": "keyword.on.graphql" 77 | }, 78 | "4": { 79 | "name": "support.type.graphql" 80 | } 81 | }, 82 | "patterns": [ 83 | { 84 | "include": "#graphql-comment" 85 | }, 86 | { 87 | "include": "#graphql-selection-set" 88 | }, 89 | { 90 | "include": "#graphql-directive" 91 | }, 92 | { 93 | "include": "#graphql-skip-newlines" 94 | }, 95 | { 96 | "include": "#literal-quasi-embedded" 97 | } 98 | ] 99 | }, 100 | "graphql-query-mutation-subscription": { 101 | "match": "\\s*\\b(query|mutation|subscription)\\b", 102 | "captures": { 103 | "1": { 104 | "name": "keyword.operation.graphql" 105 | } 106 | } 107 | }, 108 | "graphql-type-interface": { 109 | "name": "meta.type.interface.graphql", 110 | "begin": "\\s*\\b(?:(extend)?\\b\\s*\\b(type)|(interface)|(input))\\b\\s*([_A-Za-z][_0-9A-Za-z]*)?", 111 | "end": "(?<=})", 112 | "captures": { 113 | "1": { 114 | "name": "keyword.extend.graphql" 115 | }, 116 | "2": { 117 | "name": "keyword.type.graphql" 118 | }, 119 | "3": { 120 | "name": "keyword.interface.graphql" 121 | }, 122 | "4": { 123 | "name": "keyword.input.graphql" 124 | }, 125 | "5": { 126 | "name": "support.type.graphql" 127 | } 128 | }, 129 | "patterns": [ 130 | { 131 | "begin": "\\s*\\b(implements)\\b\\s*", 132 | "end": "\\s*(?={)", 133 | "beginCaptures": { 134 | "1": { 135 | "name": "keyword.implements.graphql.RRR" 136 | } 137 | }, 138 | "patterns": [ 139 | { 140 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)", 141 | "captures": { 142 | "1": { 143 | "name": "support.type.graphql.XXX" 144 | } 145 | } 146 | }, 147 | { 148 | "include": "#graphql-comma" 149 | } 150 | ] 151 | }, 152 | { 153 | "include": "#graphql-directive" 154 | }, 155 | { 156 | "include": "#graphql-comment" 157 | }, 158 | { 159 | "include": "#graphql-type-object" 160 | }, 161 | { 162 | "include": "#literal-quasi-embedded" 163 | } 164 | ] 165 | }, 166 | "graphql-type-object": { 167 | "name": "meta.type.object.graphql", 168 | "begin": "\\s*({)", 169 | "end": "\\s*(})", 170 | "beginCaptures": { 171 | "1": { 172 | "name": "punctuation.operation.graphql" 173 | } 174 | }, 175 | "endCaptures": { 176 | "1": { 177 | "name": "punctuation.operation.graphql" 178 | } 179 | }, 180 | "patterns": [ 181 | { 182 | "include": "#graphql-object-type" 183 | }, 184 | { 185 | "include": "#graphql-comment" 186 | }, 187 | { 188 | "include": "#graphql-type-definition" 189 | }, 190 | { 191 | "include": "#literal-quasi-embedded" 192 | } 193 | ] 194 | }, 195 | "graphql-type-definition": { 196 | "comment": "key (optionalArgs): Type", 197 | "begin": "\\s*([_A-Za-z][_0-9A-Za-z]*)(?=\\s*\\(|:)", 198 | "end": "(?=\\s*(([_A-Za-z][_0-9A-Za-z]*)\\s*(\\(|:)|(})))|\\s*(,)", 199 | "beginCaptures": { 200 | "1": { 201 | "name": "variable.graphql" 202 | } 203 | }, 204 | "endCaptures": { 205 | "5": { 206 | "name": "punctuation.comma.graphql" 207 | } 208 | }, 209 | "patterns": [ 210 | { 211 | "include": "#graphql-comment" 212 | }, 213 | { 214 | "include": "#graphql-variable-definitions" 215 | }, 216 | { 217 | "include": "#graphql-type-object" 218 | }, 219 | { 220 | "include": "#graphql-colon" 221 | }, 222 | { 223 | "include": "#graphql-input-types" 224 | }, 225 | { 226 | "include": "#graphql-directive" 227 | }, 228 | { 229 | "include": "#literal-quasi-embedded" 230 | } 231 | ] 232 | }, 233 | "graphql-schema": { 234 | "begin": "\\s*\\b(schema)\\b", 235 | "end": "(?<=})", 236 | "beginCaptures": { 237 | "1": { 238 | "name": "keyword.schema.graphql" 239 | } 240 | }, 241 | "patterns": [ 242 | { 243 | "begin": "\\s*({)", 244 | "end": "\\s*(})", 245 | "beginCaptures": { 246 | "1": { 247 | "name": "punctuation.operation.graphql" 248 | } 249 | }, 250 | "endCaptures": { 251 | "1": { 252 | "name": "punctuation.operation.graphql" 253 | } 254 | }, 255 | "patterns": [ 256 | { 257 | "begin": "\\s*([_A-Za-z][_0-9A-Za-z]*)(?=\\s*\\(|:)", 258 | "end": "(?=\\s*(([_A-Za-z][_0-9A-Za-z]*)\\s*(\\(|:)|(})))|\\s*(,)", 259 | "beginCaptures": { 260 | "1": { 261 | "name": "variable.arguments.graphql" 262 | } 263 | }, 264 | "endCaptures": { 265 | "5": { 266 | "name": "punctuation.comma.graphql" 267 | } 268 | }, 269 | "patterns": [ 270 | { 271 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)", 272 | "captures": { 273 | "1": { 274 | "name": "support.type.graphql" 275 | } 276 | } 277 | }, 278 | { 279 | "include": "#graphql-colon" 280 | }, 281 | { 282 | "include": "#graphql-comment" 283 | }, 284 | { 285 | "include": "#graphql-skip-newlines" 286 | } 287 | ] 288 | }, 289 | { 290 | "include": "#graphql-comment" 291 | }, 292 | { 293 | "include": "#graphql-skip-newlines" 294 | } 295 | ] 296 | }, 297 | { 298 | "include": "#graphql-comment" 299 | }, 300 | { 301 | "include": "#graphql-skip-newlines" 302 | } 303 | ] 304 | }, 305 | "graphql-directive-definition": { 306 | "begin": "\\s*(\\bdirective\\b)\\s*(@[_A-Za-z][_0-9A-Za-z]*)", 307 | "end": "(?=.)", 308 | "applyEndPatternLast": 1, 309 | "beginCaptures": { 310 | "1": { 311 | "name": "keyword.directive.graphql" 312 | }, 313 | "2": { 314 | "name": "entity.name.function.directive.graphql" 315 | } 316 | }, 317 | "patterns": [ 318 | { 319 | "include": "#graphql-variable-definitions" 320 | }, 321 | { 322 | "begin": "\\s*(\\bon\\b)\\s*([_A-Za-z]*)", 323 | "end": "(?=.)", 324 | "applyEndPatternLast": 1, 325 | "beginCaptures": { 326 | "1": { 327 | "name": "keyword.on.graphql" 328 | }, 329 | "2": { 330 | "name": "support.type.location.graphql" 331 | } 332 | }, 333 | "patterns": [ 334 | { 335 | "include": "#graphql-skip-newlines" 336 | }, 337 | { 338 | "include": "#graphql-comment" 339 | }, 340 | { 341 | "include": "#literal-quasi-embedded" 342 | }, 343 | { 344 | "match": "\\s*(\\|)\\s*([_A-Za-z]*)", 345 | "captures": { 346 | "2": { 347 | "name": "support.type.location.graphql" 348 | } 349 | } 350 | } 351 | ] 352 | }, 353 | { 354 | "include": "#graphql-skip-newlines" 355 | }, 356 | { 357 | "include": "#graphql-comment" 358 | }, 359 | { 360 | "include": "#literal-quasi-embedded" 361 | } 362 | ] 363 | }, 364 | "graphql-comment": { 365 | "patterns": [ 366 | { 367 | "comment": "need to prefix comment space with a scope else Atom's reflow cmd doesn't work", 368 | "name": "comment.line.graphql.js", 369 | "match": "(\\s*)(#).*", 370 | "captures": { 371 | "1": { 372 | "name": "punctuation.whitespace.comment.leading.graphql" 373 | } 374 | } 375 | }, 376 | { 377 | "name": "comment.line.graphql.js", 378 | "begin": "(\"\"\")", 379 | "end": "(\"\"\")", 380 | "beginCaptures": { 381 | "1": { 382 | "name": "punctuation.whitespace.comment.leading.graphql" 383 | } 384 | } 385 | }, 386 | { 387 | "name": "comment.line.graphql.js", 388 | "begin": "(\")", 389 | "end": "(\")", 390 | "beginCaptures": { 391 | "1": { 392 | "name": "punctuation.whitespace.comment.leading.graphql" 393 | } 394 | } 395 | } 396 | ] 397 | }, 398 | "graphql-variable-definitions": { 399 | "begin": "\\s*(\\()", 400 | "end": "\\s*(\\))", 401 | "captures": { 402 | "1": { 403 | "name": "meta.brace.round.graphql" 404 | } 405 | }, 406 | "patterns": [ 407 | { 408 | "include": "#graphql-comment" 409 | }, 410 | { 411 | "include": "#graphql-variable-definition" 412 | }, 413 | { 414 | "include": "#literal-quasi-embedded" 415 | } 416 | ] 417 | }, 418 | "graphql-variable-definition": { 419 | "comment": "variable: type = value,.... which may be a list", 420 | "name": "meta.variables.graphql", 421 | "begin": "\\s*(\\$?[_A-Za-z][_0-9A-Za-z]*)(?=\\s*\\(|:)", 422 | "end": "(?=\\s*((\\$?[_A-Za-z][_0-9A-Za-z]*)\\s*(\\(|:)|(}|\\))))|\\s*(,)", 423 | "beginCaptures": { 424 | "1": { 425 | "name": "variable.parameter.graphql" 426 | } 427 | }, 428 | "endCaptures": { 429 | "5": { 430 | "name": "punctuation.comma.graphql" 431 | } 432 | }, 433 | "patterns": [ 434 | { 435 | "include": "#graphql-comment" 436 | }, 437 | { 438 | "include": "#graphql-colon" 439 | }, 440 | { 441 | "include": "#graphql-input-types" 442 | }, 443 | { 444 | "include": "#graphql-variable-assignment" 445 | }, 446 | { 447 | "include": "#literal-quasi-embedded" 448 | }, 449 | { 450 | "include": "#graphql-skip-newlines" 451 | } 452 | ] 453 | }, 454 | "graphql-input-types": { 455 | "patterns": [ 456 | { 457 | "include": "#graphql-scalar-type" 458 | }, 459 | { 460 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)(?:\\s*(!))?", 461 | "captures": { 462 | "1": { 463 | "name": "support.type.graphql" 464 | }, 465 | "2": { 466 | "name": "keyword.operator.nulltype.graphql" 467 | } 468 | } 469 | }, 470 | { 471 | "name": "meta.type.list.graphql", 472 | "begin": "\\s*(\\[)", 473 | "end": "\\s*(\\])(?:\\s*(!))?", 474 | "captures": { 475 | "1": { 476 | "name": "meta.brace.square.graphql" 477 | }, 478 | "2": { 479 | "name": "keyword.operator.nulltype.graphql" 480 | } 481 | }, 482 | "patterns": [ 483 | { 484 | "include": "#graphql-input-types" 485 | }, 486 | { 487 | "include": "#graphql-comment" 488 | }, 489 | { 490 | "include": "#graphql-comma" 491 | }, 492 | { 493 | "include": "#literal-quasi-embedded" 494 | } 495 | ] 496 | } 497 | ] 498 | }, 499 | "graphql-scalar": { 500 | "match": "\\s*\\b(scalar)\\b\\s*([_A-Za-z][_0-9A-Za-z]*)", 501 | "captures": { 502 | "1": { 503 | "name": "keyword.scalar.graphql" 504 | }, 505 | "2": { 506 | "name": "entity.scalar.graphql" 507 | } 508 | } 509 | }, 510 | "graphql-scalar-type": { 511 | "match": "\\s*\\b(Int|Float|String|Boolean|ID)\\b(?:\\s*(!))?", 512 | "captures": { 513 | "1": { 514 | "name": "support.type.builtin.graphql" 515 | }, 516 | "2": { 517 | "name": "keyword.operator.nulltype.graphql" 518 | } 519 | } 520 | }, 521 | "graphql-variable-assignment": { 522 | "begin": "\\s(=)", 523 | "end": "(?=[\n,)])", 524 | "applyEndPatternLast": 1, 525 | "beginCaptures": { 526 | "1": { 527 | "name": "punctuation.assignment.graphql" 528 | } 529 | }, 530 | "patterns": [ 531 | { 532 | "include": "#graphql-value" 533 | } 534 | ] 535 | }, 536 | "graphql-comma": { 537 | "match": "\\s*(,)", 538 | "captures": { 539 | "1": { 540 | "name": "punctuation.comma.graphql" 541 | } 542 | } 543 | }, 544 | "graphql-colon": { 545 | "match": "\\s*(:)", 546 | "captures": { 547 | "1": { 548 | "name": "punctuation.colon.graphql" 549 | } 550 | } 551 | }, 552 | "graphql-union-mark": { 553 | "match": "\\s*(\\|)", 554 | "captures": { 555 | "1": { 556 | "name": "punctuation.union.graphql" 557 | } 558 | } 559 | }, 560 | "graphql-name": { 561 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)", 562 | "captures": { 563 | "1": { 564 | "name": "entity.name.function.graphql" 565 | } 566 | } 567 | }, 568 | "graphql-directive": { 569 | "begin": "\\s*((@)\\s*([_A-Za-z][_0-9A-Za-z]*))", 570 | "end": "(?=.)", 571 | "applyEndPatternLast": 1, 572 | "beginCaptures": { 573 | "1": { 574 | "name": "entity.name.function.directive.graphql" 575 | } 576 | }, 577 | "patterns": [ 578 | { 579 | "include": "#graphql-arguments" 580 | }, 581 | { 582 | "include": "#graphql-comment" 583 | }, 584 | { 585 | "include": "#literal-quasi-embedded" 586 | }, 587 | { 588 | "include": "#graphql-skip-newlines" 589 | } 590 | ] 591 | }, 592 | "graphql-selection-set": { 593 | "name": "meta.selectionset.graphql", 594 | "begin": "\\s*({)", 595 | "end": "\\s*(})", 596 | "beginCaptures": { 597 | "1": { 598 | "name": "punctuation.operation.graphql" 599 | } 600 | }, 601 | "endCaptures": { 602 | "1": { 603 | "name": "punctuation.operation.graphql" 604 | } 605 | }, 606 | "patterns": [ 607 | { 608 | "include": "#graphql-field" 609 | }, 610 | { 611 | "include": "#graphql-fragment-spread" 612 | }, 613 | { 614 | "include": "#graphql-inline-fragment" 615 | }, 616 | { 617 | "include": "#graphql-comma" 618 | }, 619 | { 620 | "include": "#graphql-comment" 621 | }, 622 | { 623 | "include": "#native-interpolation" 624 | }, 625 | { 626 | "include": "#literal-quasi-embedded" 627 | } 628 | ] 629 | }, 630 | "graphql-field": { 631 | "patterns": [ 632 | { 633 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)\\s*(:)", 634 | "captures": { 635 | "1": { 636 | "name": "string.unquoted.alias.graphql" 637 | }, 638 | "2": { 639 | "name": "punctuation.colon.graphql" 640 | } 641 | } 642 | }, 643 | { 644 | "match": "\\s*([_A-Za-z][_0-9A-Za-z]*)", 645 | "captures": { 646 | "1": { 647 | "name": "variable.graphql" 648 | } 649 | } 650 | }, 651 | { 652 | "include": "#graphql-arguments" 653 | }, 654 | { 655 | "include": "#graphql-directive" 656 | }, 657 | { 658 | "include": "#graphql-selection-set" 659 | }, 660 | { 661 | "include": "#literal-quasi-embedded" 662 | }, 663 | { 664 | "include": "#graphql-skip-newlines" 665 | } 666 | ] 667 | }, 668 | "graphql-fragment-spread": { 669 | "begin": "\\s*(\\.\\.\\.)\\s*(?!\\bon\\b)([_A-Za-z][_0-9A-Za-z]*)", 670 | "end": "(?=.)", 671 | "applyEndPatternLast": 1, 672 | "captures": { 673 | "1": { 674 | "name": "keyword.operator.spread.graphql" 675 | }, 676 | "2": { 677 | "name": "variable.fragment.graphql" 678 | } 679 | }, 680 | "patterns": [ 681 | { 682 | "include": "#graphql-comment" 683 | }, 684 | { 685 | "include": "#graphql-selection-set" 686 | }, 687 | { 688 | "include": "#graphql-directive" 689 | }, 690 | { 691 | "include": "#literal-quasi-embedded" 692 | }, 693 | { 694 | "include": "#graphql-skip-newlines" 695 | } 696 | ] 697 | }, 698 | "graphql-inline-fragment": { 699 | "begin": "\\s*(\\.\\.\\.)\\s*(?:(\\bon\\b)\\s*([_A-Za-z][_0-9A-Za-z]*))?", 700 | "end": "(?=.)", 701 | "applyEndPatternLast": 1, 702 | "captures": { 703 | "1": { 704 | "name": "keyword.operator.spread.graphql" 705 | }, 706 | "2": { 707 | "name": "keyword.on.graphql" 708 | }, 709 | "3": { 710 | "name": "support.type.graphql" 711 | } 712 | }, 713 | "patterns": [ 714 | { 715 | "include": "#graphql-comment" 716 | }, 717 | { 718 | "include": "#graphql-selection-set" 719 | }, 720 | { 721 | "include": "#graphql-directive" 722 | }, 723 | { 724 | "include": "#graphql-skip-newlines" 725 | }, 726 | { 727 | "include": "#literal-quasi-embedded" 728 | } 729 | ] 730 | }, 731 | "graphql-arguments": { 732 | "name": "meta.arguments.graphql", 733 | "begin": "\\s*(\\()", 734 | "end": "\\s*(\\))", 735 | "beginCaptures": { 736 | "1": { 737 | "name": "meta.brace.round.directive.graphql" 738 | } 739 | }, 740 | "endCaptures": { 741 | "1": { 742 | "name": "meta.brace.round.directive.graphql" 743 | } 744 | }, 745 | "patterns": [ 746 | { 747 | "include": "#graphql-comment" 748 | }, 749 | { 750 | "begin": "\\s*([_A-Za-z][_0-9A-Za-z]*)(?:\\s*(:))", 751 | "end": "(?=\\s*(?:(?:([_A-Za-z][_0-9A-Za-z]*)\\s*(:))|\\)))|\\s*(,)", 752 | "beginCaptures": { 753 | "1": { 754 | "name": "variable.parameter.graphql" 755 | }, 756 | "2": { 757 | "name": "punctuation.colon.graphql" 758 | } 759 | }, 760 | "endCaptures": { 761 | "3": { 762 | "name": "punctuation.comma.graphql" 763 | } 764 | }, 765 | "patterns": [ 766 | { 767 | "include": "#graphql-value" 768 | }, 769 | { 770 | "include": "#graphql-comment" 771 | }, 772 | { 773 | "include": "#graphql-skip-newlines" 774 | } 775 | ] 776 | }, 777 | { 778 | "include": "#literal-quasi-embedded" 779 | } 780 | ] 781 | }, 782 | "graphql-variable-name": { 783 | "match": "\\s*(\\$[_A-Za-z][_0-9A-Za-z]*)", 784 | "captures": { 785 | "1": { 786 | "name": "variable.graphql" 787 | } 788 | } 789 | }, 790 | "graphql-float-value": { 791 | "match": "\\s*(-?(0|[1-9][0-9]*)(\\.[0-9]+)?((e|E)(\\+|-)?[0-9]+)?)", 792 | "captures": { 793 | "1": { 794 | "name": "constant.numeric.float.graphql" 795 | } 796 | } 797 | }, 798 | "graphql-boolean-value": { 799 | "match": "\\s*\\b(true|false)\\b", 800 | "captures": { 801 | "1": { 802 | "name": "constant.language.boolean.graphql" 803 | } 804 | } 805 | }, 806 | "graphql-null-value": { 807 | "match": "\\s*\\b(null)\\b", 808 | "captures": { 809 | "1": { 810 | "name": "constant.language.null.graphql" 811 | } 812 | } 813 | }, 814 | "graphql-string-value": { 815 | "contentName": "string.quoted.double.graphql", 816 | "begin": "\\s*+((\"))", 817 | "end": "\\s*+(?:((\"))|(\n))", 818 | "beginCaptures": { 819 | "1": { 820 | "name": "string.quoted.double.graphql" 821 | }, 822 | "2": { 823 | "name": "punctuation.definition.string.begin.graphql" 824 | } 825 | }, 826 | "endCaptures": { 827 | "1": { 828 | "name": "string.quoted.double.graphql" 829 | }, 830 | "2": { 831 | "name": "punctuation.definition.string.end.graphql" 832 | }, 833 | "3": { 834 | "name": "invalid.illegal.newline.graphql" 835 | } 836 | }, 837 | "patterns": [ 838 | { 839 | "include": "#graphql-string-content" 840 | }, 841 | { 842 | "include": "#literal-quasi-embedded" 843 | } 844 | ] 845 | }, 846 | "graphql-string-content": { 847 | "patterns": [ 848 | { 849 | "name": "constant.character.escape.graphql", 850 | "match": "\\\\[/'\"\\\\nrtbf]" 851 | }, 852 | { 853 | "name": "constant.character.escape.graphql", 854 | "match": "\\\\u([0-9a-fA-F]{4})" 855 | } 856 | ] 857 | }, 858 | "graphql-enum": { 859 | "name": "meta.enum.graphql", 860 | "begin": "\\s*+\\b(enum)\\b\\s*([_A-Za-z][_0-9A-Za-z]*)", 861 | "end": "(?<=})", 862 | "beginCaptures": { 863 | "1": { 864 | "name": "keyword.enum.graphql" 865 | }, 866 | "2": { 867 | "name": "support.type.enum.graphql" 868 | } 869 | }, 870 | "patterns": [ 871 | { 872 | "name": "meta.type.object.graphql", 873 | "begin": "\\s*({)", 874 | "end": "\\s*(})", 875 | "beginCaptures": { 876 | "1": { 877 | "name": "punctuation.operation.graphql" 878 | } 879 | }, 880 | "endCaptures": { 881 | "1": { 882 | "name": "punctuation.operation.graphql" 883 | } 884 | }, 885 | "patterns": [ 886 | { 887 | "include": "#graphql-object-type" 888 | }, 889 | { 890 | "include": "#graphql-comment" 891 | }, 892 | { 893 | "include": "#graphql-enum-value" 894 | }, 895 | { 896 | "include": "#literal-quasi-embedded" 897 | } 898 | ] 899 | } 900 | ] 901 | }, 902 | "graphql-enum-value": { 903 | "name": "constant.character.enum.graphql", 904 | "match": "\\s*(?!=\\b(true|false|null)\\b)([_A-Za-z][_0-9A-Za-z]*)" 905 | }, 906 | "graphql-value": { 907 | "patterns": [ 908 | { 909 | "include": "#graphql-variable-name" 910 | }, 911 | { 912 | "include": "#graphql-float-value" 913 | }, 914 | { 915 | "include": "#graphql-string-value" 916 | }, 917 | { 918 | "include": "#graphql-boolean-value" 919 | }, 920 | { 921 | "include": "#graphql-null-value" 922 | }, 923 | { 924 | "include": "#graphql-enum-value" 925 | }, 926 | { 927 | "include": "#graphql-list-value" 928 | }, 929 | { 930 | "include": "#graphql-object-value" 931 | }, 932 | { 933 | "include": "#graphql-comment" 934 | }, 935 | { 936 | "include": "#literal-quasi-embedded" 937 | } 938 | ] 939 | }, 940 | "graphql-list-value": { 941 | "patterns": [ 942 | { 943 | "name": "meta.listvalues.graphql", 944 | "begin": "\\s*+(\\[)", 945 | "end": "\\s*(\\])", 946 | "endCaptures": { 947 | "1": { 948 | "name": "meta.brace.square.graphql" 949 | } 950 | }, 951 | "beginCaptures": { 952 | "1": { 953 | "name": "meta.brace.square.graphql" 954 | } 955 | }, 956 | "patterns": [ 957 | { 958 | "include": "#graphql-value" 959 | } 960 | ] 961 | } 962 | ] 963 | }, 964 | "graphql-object-value": { 965 | "patterns": [ 966 | { 967 | "name": "meta.objectvalues.graphql", 968 | "begin": "\\s*+({)", 969 | "end": "\\s*(})", 970 | "beginCaptures": { 971 | "1": { 972 | "name": "meta.brace.curly.graphql" 973 | } 974 | }, 975 | "endCaptures": { 976 | "1": { 977 | "name": "meta.brace.curly.graphql" 978 | } 979 | }, 980 | "patterns": [ 981 | { 982 | "include": "#graphql-object-field" 983 | }, 984 | { 985 | "include": "#graphql-value" 986 | } 987 | ] 988 | } 989 | ] 990 | }, 991 | "graphql-object-field": { 992 | "match": "\\s*(([_A-Za-z][_0-9A-Za-z]*))\\s*(:)", 993 | "captures": { 994 | "1": { 995 | "name": "constant.object.key.graphql" 996 | }, 997 | "2": { 998 | "name": "string.unquoted.graphql" 999 | }, 1000 | "3": { 1001 | "name": "punctuation.graphql" 1002 | } 1003 | } 1004 | }, 1005 | "graphql-union": { 1006 | "begin": "\\s*\\b(union)\\b\\s*([_A-Za-z][_0-9A-Za-z]*)", 1007 | "end": "(?=.)", 1008 | "applyEndPatternLast": 1, 1009 | "captures": { 1010 | "1": { 1011 | "name": "keyword.union.graphql" 1012 | }, 1013 | "2": { 1014 | "name": "support.type.graphql" 1015 | } 1016 | }, 1017 | "patterns": [ 1018 | { 1019 | "begin": "\\s*(=)\\s*([_A-Za-z][_0-9A-Za-z]*)", 1020 | "end": "(?=.)", 1021 | "applyEndPatternLast": 1, 1022 | "captures": { 1023 | "1": { 1024 | "name": "punctuation.assignment.graphql" 1025 | }, 1026 | "2": { 1027 | "name": "support.type.graphql" 1028 | } 1029 | }, 1030 | "patterns": [ 1031 | { 1032 | "include": "#graphql-skip-newlines" 1033 | }, 1034 | { 1035 | "include": "#graphql-comment" 1036 | }, 1037 | { 1038 | "include": "#literal-quasi-embedded" 1039 | }, 1040 | { 1041 | "match": "\\s*(\\|)\\s*([_A-Za-z][_0-9A-Za-z]*)", 1042 | "captures": { 1043 | "1": { 1044 | "name": "punctuation.or.graphql" 1045 | }, 1046 | "2": { 1047 | "name": "support.type.graphql" 1048 | } 1049 | } 1050 | } 1051 | ] 1052 | }, 1053 | { 1054 | "include": "#graphql-skip-newlines" 1055 | }, 1056 | { 1057 | "include": "#graphql-comment" 1058 | }, 1059 | { 1060 | "include": "#literal-quasi-embedded" 1061 | } 1062 | ] 1063 | }, 1064 | "native-interpolation": { 1065 | "name": "native.interpolation", 1066 | "begin": "\\s*(\\${)", 1067 | "end": "(})", 1068 | "beginCaptures": { 1069 | "1": { 1070 | "name": "keyword.other.substitution.begin" 1071 | } 1072 | }, 1073 | "endCaptures": { 1074 | "1": { 1075 | "name": "keyword.other.substitution.end" 1076 | } 1077 | }, 1078 | "patterns": [ 1079 | { 1080 | "include": "source.js" 1081 | }, 1082 | { 1083 | "include": "source.ts" 1084 | }, 1085 | { 1086 | "include": "source.jsx" 1087 | }, 1088 | { 1089 | "include": "source.tsx" 1090 | } 1091 | ] 1092 | }, 1093 | "graphql-skip-newlines": { 1094 | "match": "\\s*\n" 1095 | } 1096 | } 1097 | } 1098 | -------------------------------------------------------------------------------- /syntaxes/graphql.res.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileTypes": ["res", "resi"], 3 | "injectionSelector": "L:source -string -comment", 4 | "patterns": [ 5 | { 6 | "contentName": "meta.embedded.block.graphql", 7 | "begin": "(%relay\\()\\s*$", 8 | "end": "(?<=\\))", 9 | "patterns": [ 10 | { 11 | "begin": "^\\s*(`)$", 12 | "end": "^\\s*(`)", 13 | "patterns": [{ "include": "source.graphql" }] 14 | } 15 | ] 16 | }, 17 | { 18 | "contentName": "meta.embedded.block.graphql", 19 | "begin": "(%relay\\(`)", 20 | "end": "(\\`( )?\\))", 21 | "patterns": [{ "include": "source.graphql" }] 22 | } 23 | ], 24 | "scopeName": "inline.graphql.rescript" 25 | } 26 | -------------------------------------------------------------------------------- /syntaxes/rescriptRelayRouter.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RescriptRelayRouter", 3 | "scopeName": "source.rescriptRelayRouter", 4 | "patterns": [ 5 | { 6 | "include": "#value" 7 | } 8 | ], 9 | "repository": { 10 | "array": { 11 | "begin": "\\[", 12 | "beginCaptures": { 13 | "0": { 14 | "name": "punctuation.definition.array.begin.json" 15 | } 16 | }, 17 | "end": "\\]", 18 | "endCaptures": { 19 | "0": { 20 | "name": "punctuation.definition.array.end.json" 21 | } 22 | }, 23 | "name": "meta.structure.array.json", 24 | "patterns": [ 25 | { 26 | "include": "#value" 27 | }, 28 | { 29 | "match": ",", 30 | "name": "punctuation.separator.array.json" 31 | }, 32 | { 33 | "match": "[^\\s\\]]", 34 | "name": "invalid.illegal.expected-array-separator.json" 35 | } 36 | ] 37 | }, 38 | "comments": { 39 | "patterns": [ 40 | { 41 | "begin": "/\\*\\*(?!/)", 42 | "captures": { 43 | "0": { 44 | "name": "punctuation.definition.comment.json" 45 | } 46 | }, 47 | "end": "\\*/", 48 | "name": "comment.block.documentation.json" 49 | }, 50 | { 51 | "begin": "/\\*", 52 | "captures": { 53 | "0": { 54 | "name": "punctuation.definition.comment.json" 55 | } 56 | }, 57 | "end": "\\*/", 58 | "name": "comment.block.json" 59 | }, 60 | { 61 | "captures": { 62 | "1": { 63 | "name": "punctuation.definition.comment.json" 64 | } 65 | }, 66 | "match": "(//).*$\\n?", 67 | "name": "comment.line.double-slash.js" 68 | } 69 | ] 70 | }, 71 | "constant": { 72 | "match": "\\b(?:true|false|null)\\b", 73 | "name": "constant.language.json" 74 | }, 75 | "object": { 76 | "begin": "\\{", 77 | "beginCaptures": { 78 | "0": { 79 | "name": "punctuation.definition.dictionary.begin.json" 80 | } 81 | }, 82 | "end": "\\}", 83 | "endCaptures": { 84 | "0": { 85 | "name": "punctuation.definition.dictionary.end.json" 86 | } 87 | }, 88 | "name": "meta.structure.dictionary.json", 89 | "patterns": [ 90 | { 91 | "begin": "(\"name\":)", 92 | "beginCaptures": { 93 | "1": { 94 | "name": "support.type.property-name.json" 95 | } 96 | }, 97 | "end": "(,)|(?=\\})", 98 | "name": "string.json support.type.property-name.json", 99 | "patterns": [ 100 | { 101 | "begin": "\"", 102 | "beginCaptures": { 103 | "0": { 104 | "name": "punctuation.definition.string.begin.json" 105 | } 106 | }, 107 | "end": "\"", 108 | "endCaptures": { 109 | "0": { 110 | "name": "punctuation.definition.string.end.json" 111 | } 112 | }, 113 | "name": "string.quoted.double.json", 114 | "patterns": [ 115 | { 116 | "match": ".", 117 | "name": "string.quoted.double.json" 118 | } 119 | ] 120 | } 121 | ] 122 | }, 123 | { 124 | "begin": "(\"path\":)", 125 | "beginCaptures": { 126 | "1": { 127 | "name": "support.type.property-name.json" 128 | } 129 | }, 130 | "end": "(,)|(?=\\})", 131 | "name": "string.json support.type.property-name.json", 132 | "patterns": [ 133 | { 134 | "begin": "\"", 135 | "beginCaptures": { 136 | "0": { 137 | "name": "punctuation.definition.string.begin.json" 138 | } 139 | }, 140 | "end": "\"", 141 | "endCaptures": { 142 | "0": { 143 | "name": "punctuation.definition.string.end.json" 144 | } 145 | }, 146 | "name": "string.quoted.double.json", 147 | "patterns": [ 148 | { 149 | "match": "[/.=&]", 150 | "name": "punctuation.definition.tag" 151 | }, 152 | { 153 | "match": "(:[a-z][A-Za-z0-9_]+)", 154 | "name": "variable.object.property" 155 | }, 156 | { 157 | "match": "[\\?]", 158 | "name": "keyword.operator" 159 | }, 160 | { 161 | "match": "([a-z0-9A-Z_]+)(=)([a-z0-9A-Z\\._<>]+)", 162 | "captures": { 163 | "1": { 164 | "name": "variable.object.property" 165 | }, 166 | "2": { 167 | "name": "punctuation.definition.tag" 168 | }, 169 | "3": { 170 | "name": "support.type", 171 | "patterns": [ 172 | { 173 | "match": "[/.=&<>]", 174 | "name": "punctuation.definition.tag" 175 | } 176 | ] 177 | } 178 | } 179 | } 180 | ] 181 | } 182 | ] 183 | }, 184 | { 185 | "comment": "the JSON object key", 186 | "include": "#objectkey" 187 | }, 188 | { 189 | "include": "#comments" 190 | }, 191 | { 192 | "begin": ":", 193 | "beginCaptures": { 194 | "0": { 195 | "name": "punctuation.separator.dictionary.key-value.json" 196 | } 197 | }, 198 | "end": "(,)|(?=\\})", 199 | "endCaptures": { 200 | "1": { 201 | "name": "punctuation.separator.dictionary.pair.json" 202 | } 203 | }, 204 | "name": "meta.structure.dictionary.value.json", 205 | "patterns": [ 206 | { 207 | "comment": "the JSON object value", 208 | "include": "#value" 209 | }, 210 | { 211 | "match": "[^\\s,]", 212 | "name": "invalid.illegal.expected-dictionary-separator.json" 213 | } 214 | ] 215 | }, 216 | { 217 | "match": "[^\\s\\}]", 218 | "name": "invalid.illegal.expected-dictionary-separator.json" 219 | } 220 | ] 221 | }, 222 | "string": { 223 | "begin": "\"", 224 | "beginCaptures": { 225 | "0": { 226 | "name": "punctuation.definition.string.begin.json" 227 | } 228 | }, 229 | "end": "\"", 230 | "endCaptures": { 231 | "0": { 232 | "name": "punctuation.definition.string.end.json" 233 | } 234 | }, 235 | "name": "string.quoted.double.json", 236 | "patterns": [ 237 | { 238 | "include": "#stringcontent" 239 | } 240 | ] 241 | }, 242 | "objectkey": { 243 | "begin": "\"", 244 | "beginCaptures": { 245 | "0": { 246 | "name": "punctuation.support.type.property-name.begin.json" 247 | } 248 | }, 249 | "end": "\"", 250 | "endCaptures": { 251 | "0": { 252 | "name": "punctuation.support.type.property-name.end.json" 253 | } 254 | }, 255 | "name": "string.json support.type.property-name.json", 256 | "patterns": [ 257 | { 258 | "include": "#stringcontent" 259 | } 260 | ] 261 | }, 262 | 263 | "stringcontent": { 264 | "patterns": [ 265 | { 266 | "match": "(?x) # turn on extended mode\n \\\\ # a literal backslash\n (?: # ...followed by...\n [\"\\\\/bfnrt] # one of these characters\n | # ...or...\n u # a u\n [0-9a-fA-F]{4}) # and four hex digits", 267 | "name": "constant.character.escape.json" 268 | }, 269 | { 270 | "match": "\\\\.", 271 | "name": "invalid.illegal.unrecognized-string-escape.json" 272 | } 273 | ] 274 | }, 275 | "value": { 276 | "patterns": [ 277 | { 278 | "include": "#constant" 279 | }, 280 | { 281 | "include": "#number" 282 | }, 283 | { 284 | "include": "#string" 285 | }, 286 | { 287 | "include": "#array" 288 | }, 289 | { 290 | "include": "#object" 291 | }, 292 | { 293 | "include": "#comments" 294 | } 295 | ] 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "outDir": "build", 6 | "moduleResolution": "node", 7 | "lib": ["dom", "es2017", "esnext"], 8 | "types": ["node", "jest"], 9 | "sourceMap": false, 10 | "esModuleInterop": true, 11 | "removeComments": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "strict": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // Not used right now 2 | const path = require("path"); 3 | 4 | const isProd = process.env.NODE_ENV === "production"; 5 | 6 | const config = { 7 | target: "node", 8 | entry: { 9 | extension: "./src/extension.ts", 10 | server: "./src/server.ts" 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, "build"), 14 | filename: "[name].js", 15 | libraryTarget: "commonjs2" 16 | }, 17 | devtool: "source-map", 18 | externals: { 19 | vscode: "commonjs vscode", 20 | "vscode-languageserver": "vscode-languageserver", 21 | "vscode-languageserver-protocol": "vscode-languageserver-protocol", 22 | encoding: "encoding" 23 | }, 24 | resolve: { 25 | extensions: [".mjs", ".js", ".ts"] 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | exclude: /node_modules/, 32 | use: [ 33 | { 34 | loader: "ts-loader", 35 | options: { 36 | compilerOptions: { 37 | module: "es6" 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | ] 44 | } 45 | }; 46 | 47 | module.exports = config; 48 | --------------------------------------------------------------------------------