diff --git a/.changeset/many-pandas-relax.md b/.changeset/many-pandas-relax.md new file mode 100644 index 00000000..ad730829 --- /dev/null +++ b/.changeset/many-pandas-relax.md @@ -0,0 +1,5 @@ +--- +"@0no-co/graphqlsp": minor +--- + +Make the LSP work with [`gql.tada`](https://github.com/0no-co/gql.tada) diff --git a/packages/example-tada/.vscode/settings.json b/packages/example-tada/.vscode/settings.json new file mode 100644 index 00000000..25fa6215 --- /dev/null +++ b/packages/example-tada/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/packages/example-tada/introspection.ts b/packages/example-tada/introspection.ts new file mode 100644 index 00000000..436371c3 --- /dev/null +++ b/packages/example-tada/introspection.ts @@ -0,0 +1,418 @@ +export const introspection = { + __schema: { + queryType: { + name: 'Query', + }, + mutationType: null, + subscriptionType: null, + types: [ + { + kind: 'OBJECT', + name: 'Attack', + fields: [ + { + name: 'damage', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + args: [], + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + { + name: 'type', + type: { + kind: 'ENUM', + name: 'PokemonType', + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'SCALAR', + name: 'Int', + }, + { + kind: 'SCALAR', + name: 'String', + }, + { + kind: 'OBJECT', + name: 'AttacksConnection', + fields: [ + { + name: 'fast', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'Attack', + ofType: null, + }, + }, + args: [], + }, + { + name: 'special', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'Attack', + ofType: null, + }, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'EvolutionRequirement', + fields: [ + { + name: 'amount', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + args: [], + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'Pokemon', + fields: [ + { + name: 'attacks', + type: { + kind: 'OBJECT', + name: 'AttacksConnection', + ofType: null, + }, + args: [], + }, + { + name: 'classification', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + { + name: 'evolutionRequirements', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'EvolutionRequirement', + ofType: null, + }, + }, + args: [], + }, + { + name: 'evolutions', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'Pokemon', + ofType: null, + }, + }, + args: [], + }, + { + name: 'fleeRate', + type: { + kind: 'SCALAR', + name: 'Float', + ofType: null, + }, + args: [], + }, + { + name: 'height', + type: { + kind: 'OBJECT', + name: 'PokemonDimension', + ofType: null, + }, + args: [], + }, + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + ofType: null, + }, + }, + args: [], + }, + { + name: 'maxCP', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + args: [], + }, + { + name: 'maxHP', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + args: [], + }, + { + name: 'name', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + args: [], + }, + { + name: 'resistant', + type: { + kind: 'LIST', + ofType: { + kind: 'ENUM', + name: 'PokemonType', + ofType: null, + }, + }, + args: [], + }, + { + name: 'types', + type: { + kind: 'LIST', + ofType: { + kind: 'ENUM', + name: 'PokemonType', + ofType: null, + }, + }, + args: [], + }, + { + name: 'weaknesses', + type: { + kind: 'LIST', + ofType: { + kind: 'ENUM', + name: 'PokemonType', + ofType: null, + }, + }, + args: [], + }, + { + name: 'weight', + type: { + kind: 'OBJECT', + name: 'PokemonDimension', + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'SCALAR', + name: 'Float', + }, + { + kind: 'SCALAR', + name: 'ID', + }, + { + kind: 'OBJECT', + name: 'PokemonDimension', + fields: [ + { + name: 'maximum', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + { + name: 'minimum', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'ENUM', + name: 'PokemonType', + enumValues: [ + { + name: 'Bug', + }, + { + name: 'Dark', + }, + { + name: 'Dragon', + }, + { + name: 'Electric', + }, + { + name: 'Fairy', + }, + { + name: 'Fighting', + }, + { + name: 'Fire', + }, + { + name: 'Flying', + }, + { + name: 'Ghost', + }, + { + name: 'Grass', + }, + { + name: 'Ground', + }, + { + name: 'Ice', + }, + { + name: 'Normal', + }, + { + name: 'Poison', + }, + { + name: 'Psychic', + }, + { + name: 'Rock', + }, + { + name: 'Steel', + }, + { + name: 'Water', + }, + ], + }, + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'pokemon', + type: { + kind: 'OBJECT', + name: 'Pokemon', + ofType: null, + }, + args: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + ofType: null, + }, + }, + }, + ], + }, + { + name: 'pokemons', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'Pokemon', + ofType: null, + }, + }, + args: [ + { + name: 'limit', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + }, + { + name: 'skip', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + }, + ], + }, + ], + interfaces: [], + }, + { + kind: 'SCALAR', + name: 'Boolean', + }, + ], + directives: [], + }, +} as const; diff --git a/packages/example-tada/package.json b/packages/example-tada/package.json new file mode 100644 index 00000000..8a41d992 --- /dev/null +++ b/packages/example-tada/package.json @@ -0,0 +1,27 @@ +{ + "name": "example", + "private": true, + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "gql.tada": "*", + "@urql/core": "^3.0.0", + "graphql": "^16.8.1", + "urql": "^4.0.6" + }, + "devDependencies": { + "@0no-co/graphqlsp": "file:../graphqlsp", + "@graphql-codegen/cli": "^5.0.0", + "@graphql-codegen/client-preset": "^4.1.0", + "@types/react": "^18.2.45", + "ts-node": "^10.9.1", + "typescript": "^5.3.3" + } +} diff --git a/packages/example-tada/schema.graphql b/packages/example-tada/schema.graphql new file mode 100644 index 00000000..148f0b7c --- /dev/null +++ b/packages/example-tada/schema.graphql @@ -0,0 +1,94 @@ +### This file was generated by Nexus Schema +### Do not make changes to this file directly + +""" +Move a Pokémon can perform with the associated damage and type. +""" +type Attack { + damage: Int + name: String + type: PokemonType +} + +type AttacksConnection { + fast: [Attack] + special: [Attack] +} + +""" +Requirement that prevents an evolution through regular means of levelling up. +""" +type EvolutionRequirement { + amount: Int + name: String +} + +type Pokemon { + attacks: AttacksConnection + classification: String @deprecated(reason: "And this is the reason why") + evolutionRequirements: [EvolutionRequirement] + evolutions: [Pokemon] + + """ + Likelihood of an attempt to catch a Pokémon to fail. + """ + fleeRate: Float + height: PokemonDimension + id: ID! + + """ + Maximum combat power a Pokémon may achieve at max level. + """ + maxCP: Int + + """ + Maximum health points a Pokémon may achieve at max level. + """ + maxHP: Int + name: String! + resistant: [PokemonType] + types: [PokemonType] + weaknesses: [PokemonType] + weight: PokemonDimension +} + +type PokemonDimension { + maximum: String + minimum: String +} + +""" +Elemental property associated with either a Pokémon or one of their moves. +""" +enum PokemonType { + Bug + Dark + Dragon + Electric + Fairy + Fighting + Fire + Flying + Ghost + Grass + Ground + Ice + Normal + Poison + Psychic + Rock + Steel + Water +} + +type Query { + """ + Get a single Pokémon by its ID, a three character long identifier padded with zeroes + """ + pokemon(id: ID!): Pokemon + + """ + List out all Pokémon, optionally in pages + """ + pokemons(limit: Int, skip: Int): [Pokemon] +} \ No newline at end of file diff --git a/packages/example-tada/src/Pokemon.tsx b/packages/example-tada/src/Pokemon.tsx new file mode 100644 index 00000000..209a599c --- /dev/null +++ b/packages/example-tada/src/Pokemon.tsx @@ -0,0 +1,27 @@ +import { FragmentOf, graphql, readFragment } from './graphql'; + +export const PokemonFields = graphql(` + fragment pokemonFields on Pokemon { + name + weight { + minimum + } + } +`); + +interface Props { + data: FragmentOf | null; +} + +export const Pokemon = ({ data }: Props) => { + const pokemon = readFragment(PokemonFields, data); + if (!pokemon) { + return null; + } + + return ( +
  • + {pokemon.name} +
  • + ); +}; diff --git a/packages/example-tada/src/graphql.ts b/packages/example-tada/src/graphql.ts new file mode 100644 index 00000000..02578284 --- /dev/null +++ b/packages/example-tada/src/graphql.ts @@ -0,0 +1,9 @@ +import { initGraphQLTada } from 'gql.tada'; +import type { introspection } from '../introspection'; + +export const graphql = initGraphQLTada<{ + introspection: typeof introspection; +}>(); + +export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; +export { readFragment } from 'gql.tada'; diff --git a/packages/example-tada/src/index.tsx b/packages/example-tada/src/index.tsx new file mode 100644 index 00000000..6ec9ccb5 --- /dev/null +++ b/packages/example-tada/src/index.tsx @@ -0,0 +1,51 @@ +import { createClient, useQuery } from 'urql'; +import { graphql } from './graphql'; +import { Pokemon, PokemonFields } from './Pokemon'; + +const PokemonQuery = graphql(` + query Po($id: ID!) { + pokemon(id: $id) { + id + fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name + __typename + } + } +`, [PokemonFields]); + +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonQuery, + variables: { id: '' } + }); + + // Works + console.log(result.data?.pokemon?.attacks && result.data?.pokemon?.attacks.special && result.data?.pokemon?.attacks.special[0] && result.data?.pokemon?.attacks.special[0].name) + + // Works + const { fleeRate } = result.data?.pokemon || {}; + console.log(fleeRate) + // Works + const po = result.data?.pokemon; + // @ts-expect-error + const { pokemon: { weight: { minimum } } } = result.data || {}; + console.log(po?.name, minimum) + + // Works + const { pokemon } = result.data || {}; + console.log(pokemon?.weight?.maximum) + + return ; +} + diff --git a/packages/example-tada/tsconfig.json b/packages/example-tada/tsconfig.json new file mode 100644 index 00000000..6fd86a89 --- /dev/null +++ b/packages/example-tada/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "./schema.graphql", + "disableTypegen": true, + "shouldCheckForColocatedFragments": true, + "template": "graphql", + "templateIsCallExpression": true, + "trackFieldUsage": true + } + ], + "jsx": "react-jsx", + /* Language and Environment */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/packages/graphqlsp/src/ast/index.ts b/packages/graphqlsp/src/ast/index.ts index 3f1815d6..827ffbb3 100644 --- a/packages/graphqlsp/src/ast/index.ts +++ b/packages/graphqlsp/src/ast/index.ts @@ -1,6 +1,7 @@ import ts from 'typescript/lib/tsserverlibrary'; import fs from 'fs'; import { FragmentDefinitionNode, parse } from 'graphql'; +import { Logger } from '..'; export function isFileDirty(fileName: string, source: ts.SourceFile) { const contents = fs.readFileSync(fileName, 'utf-8'); @@ -56,6 +57,58 @@ export function findAllTaggedTemplateNodes( return result; } +function unrollFragment( + element: ts.Identifier, + template: string, + info: ts.server.PluginCreateInfo +): Array { + const fragments: Array = []; + const definitions = info.languageService.getDefinitionAtPosition( + element.getSourceFile().fileName, + element.getStart() + ); + + if (!definitions) return fragments; + + const [fragment] = definitions; + + const externalSource = getSource(info, fragment.fileName); + if (!externalSource) return fragments; + + let found = findNode(externalSource, fragment.textSpan.start); + if (!found) return fragments; + + if ( + ts.isVariableDeclaration(found.parent) && + found.parent.initializer && + ts.isCallExpression(found.parent.initializer) + ) { + found = found.parent.initializer; + } + + if (ts.isCallExpression(found) && found.expression.getText() === template) { + const [arg, arg2] = found.arguments; + if (arg2 && ts.isArrayLiteralExpression(arg2)) { + arg2.elements.forEach(element => { + if (ts.isIdentifier(element)) { + fragments.push(...unrollFragment(element, template, info)); + } + }); + } + + try { + const parsed = parse(arg.getText().slice(1, -1), { noLocation: true }); + parsed.definitions.forEach(definition => { + if (definition.kind === 'FragmentDefinition') { + fragments.push(definition); + } + }); + } catch (e) {} + } + + return fragments; +} + export function findAllCallExpressions( sourceFile: ts.SourceFile, template: string, @@ -70,11 +123,19 @@ export function findAllCallExpressions( let hasTriedToFindFragments = shouldSearchFragments ? false : true; function find(node: ts.Node) { if (ts.isCallExpression(node) && node.expression.getText() === template) { - if (!hasTriedToFindFragments) { + const [arg, arg2] = node.arguments; + + if (!hasTriedToFindFragments && !arg2) { hasTriedToFindFragments = true; fragments = getAllFragments(sourceFile.fileName, node, info); + } else if (arg2 && ts.isArrayLiteralExpression(arg2)) { + arg2.elements.forEach(element => { + if (ts.isIdentifier(element)) { + fragments.push(...unrollFragment(element, template, info)); + } + }); } - const [arg] = node.arguments; + if (arg && ts.isNoSubstitutionTemplateLiteral(arg)) { result.push(arg); } @@ -92,6 +153,7 @@ export function getAllFragments( node: ts.CallExpression, info: ts.server.PluginCreateInfo ) { + const template = info.config.template || 'gql'; let fragments: Array = []; const definitions = info.languageService.getDefinitionAtPosition( @@ -100,6 +162,16 @@ export function getAllFragments( ); if (!definitions) return fragments; + if (node.arguments[1] && ts.isArrayLiteralExpression(node.arguments[1])) { + const arg2 = node.arguments[1] as ts.ArrayLiteralExpression; + arg2.elements.forEach(element => { + if (ts.isIdentifier(element)) { + fragments.push(...unrollFragment(element, template, info)); + } + }); + return fragments; + } + const def = definitions[0]; const src = getSource(info, def.fileName); if (!src) return fragments; diff --git a/packages/graphqlsp/src/autoComplete.ts b/packages/graphqlsp/src/autoComplete.ts index 0d8f2929..7ccba481 100644 --- a/packages/graphqlsp/src/autoComplete.ts +++ b/packages/graphqlsp/src/autoComplete.ts @@ -37,9 +37,6 @@ export function getGraphQLCompletions( schema: { current: GraphQLSchema | null }, info: ts.server.PluginCreateInfo ): ts.WithMetadata | undefined { - const logger: any = (msg: string) => - info.project.projectService.logger.info(`[GraphQLSP] ${msg}`); - const tagTemplate = info.config.template || 'gql'; const isCallExpression = info.config.templateIsCallExpression ?? false; diff --git a/packages/graphqlsp/src/checkImports.ts b/packages/graphqlsp/src/checkImports.ts index 6f7093b3..05f1ac1b 100644 --- a/packages/graphqlsp/src/checkImports.ts +++ b/packages/graphqlsp/src/checkImports.ts @@ -52,67 +52,62 @@ export const checkImportsForFragments = ( const moduleExports = typeChecker?.getExportsOfModule(symbol); if (!moduleExports) return; - const missingImports = moduleExports - .map(exp => { - if (importedNames.includes(exp.name)) { - return; - } + const missingImports = new Set(); + moduleExports.forEach(exp => { + if (importedNames.includes(exp.name)) { + return; + } - const declarations = exp.getDeclarations(); - const declaration = declarations?.find(x => { - // TODO: check whether the sourceFile.fileName resembles the module - // specifier - return true; - }); + const declarations = exp.getDeclarations(); + const declaration = declarations?.find(x => { + // TODO: check whether the sourceFile.fileName resembles the module + // specifier + return true; + }); - if (!declaration) return; + if (!declaration) return; - const [template] = findAllTaggedTemplateNodes( - declaration, - tagTemplate - ); - if (template) { - let node = template; - if ( - ts.isNoSubstitutionTemplateLiteral(node) || - ts.isTemplateExpression(node) - ) { - if (ts.isTaggedTemplateExpression(node.parent)) { - node = node.parent; - } else { - return; - } + const [template] = findAllTaggedTemplateNodes(declaration, tagTemplate); + if (template) { + let node = template; + if ( + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isTemplateExpression(node) + ) { + if (ts.isTaggedTemplateExpression(node.parent)) { + node = node.parent; + } else { + return; } + } - const text = resolveTemplate( - node, - node.getSourceFile().fileName, - info - ).combinedText; - try { - const parsed = parse(text, { noLocation: true }); - if ( - parsed.definitions.every( - x => x.kind === Kind.FRAGMENT_DEFINITION - ) - ) { - return `'${exp.name}'`; - } - } catch (e) { - return; + const text = resolveTemplate( + node, + node.getSourceFile().fileName, + info + ).combinedText; + try { + const parsed = parse(text, { noLocation: true }); + if ( + parsed.definitions.every(x => x.kind === Kind.FRAGMENT_DEFINITION) + ) { + missingImports.add(`'${exp.name}'`); } + } catch (e) { + return; } - }) - .filter(Boolean); + } + }); - if (missingImports.length) { + const missing = Array.from(missingImports); + if (missing.length) { tsDiagnostics.push({ file: source, length: imp.getText().length, start: imp.getStart(), category: ts.DiagnosticCategory.Message, code: MISSING_FRAGMENT_CODE, - messageText: `Missing Fragment import(s) ${missingImports.join( + messageText: `Missing Fragment import(s) ${missing.join( ', ' )} from ${imp.moduleSpecifier.getText()}.`, }); diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index d15b96a8..fef5b8b8 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -328,10 +328,6 @@ const runDiagnostics = ( source, info ); - console.log( - '[GraphhQLSP] Checking for colocated fragments ', - JSON.stringify(moduleSpecifierToFragments, null, 2) - ); const usedFragments = new Set(); nodes.forEach(node => { @@ -353,9 +349,9 @@ const runDiagnostics = ( start, length, } = moduleSpecifierToFragments[moduleSpecifier]; - const missingFragments = fragmentNames.filter( + const missingFragments = Array.from(new Set(fragmentNames.filter( x => !usedFragments.has(x) - ); + ))); if (missingFragments.length) { fragmentDiagnostics.push({ file: source, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17fbc31e..9b700ab2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,43 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/example-tada: + dependencies: + '@graphql-typed-document-node/core': + specifier: ^3.2.0 + version: 3.2.0(graphql@16.8.1) + '@urql/core': + specifier: ^3.0.0 + version: 3.2.2(graphql@16.8.1) + gql.tada: + specifier: '*' + version: 1.0.0-beta.1(graphql@16.8.1) + graphql: + specifier: ^16.8.1 + version: 16.8.1 + urql: + specifier: ^4.0.6 + version: 4.0.6(graphql@16.8.1)(react@18.2.0) + devDependencies: + '@0no-co/graphqlsp': + specifier: file:../graphqlsp + version: file:packages/graphqlsp(graphql@16.8.1) + '@graphql-codegen/cli': + specifier: ^5.0.0 + version: 5.0.0(@types/node@18.15.11)(graphql@16.8.1)(typescript@5.3.3) + '@graphql-codegen/client-preset': + specifier: ^4.1.0 + version: 4.1.0(graphql@16.8.1) + '@types/react': + specifier: ^18.2.45 + version: 18.2.45 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@types/node@18.15.11)(typescript@5.3.3) + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages/graphqlsp: dependencies: '@graphql-codegen/add': @@ -1898,13 +1935,13 @@ packages: '@graphql-tools/executor-graphql-ws': 1.1.0(graphql@16.8.1) '@graphql-tools/executor-http': 1.0.3(@types/node@18.15.11)(graphql@16.8.1) '@graphql-tools/executor-legacy-ws': 1.0.4(graphql@16.8.1) - '@graphql-tools/utils': 10.0.1(graphql@16.8.1) + '@graphql-tools/utils': 10.0.11(graphql@16.8.1) '@graphql-tools/wrap': 10.0.1(graphql@16.8.1) '@types/ws': 8.5.10 '@whatwg-node/fetch': 0.9.14 graphql: 16.8.1 isomorphic-ws: 5.0.0(ws@8.14.2) - tslib: 2.5.0 + tslib: 2.6.2 value-or-promise: 1.0.12 ws: 8.14.2 transitivePeerDependencies: @@ -3620,6 +3657,14 @@ packages: get-intrinsic: 1.2.0 dev: true + /gql.tada@1.0.0-beta.1(graphql@16.8.1): + resolution: {integrity: sha512-jodWt1sV8ORYp8FyY8+bGXAalRoyAqjk6kAZfewZgTuFZ8v/F70yujqxQ0KSdajzFK2SLsOSPEI+QXv+Nln9EQ==} + dependencies: + '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) + transitivePeerDependencies: + - graphql + dev: false + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true