diff --git a/.changeset/soft-eggs-ring.md b/.changeset/soft-eggs-ring.md new file mode 100644 index 0000000000..fae497fef8 --- /dev/null +++ b/.changeset/soft-eggs-ring.md @@ -0,0 +1,7 @@ +--- +'graphql-language-service-server': patch +'graphql-language-service': patch +'vscode-graphql': patch +--- + +Fix positions for outlines of embedded graphql documents diff --git a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts index 5e307daf99..4f2c57b9f6 100644 --- a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts +++ b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts @@ -7,6 +7,7 @@ * */ +import * as path from 'node:path'; import { DocumentNode, FragmentSpreadNode, @@ -22,6 +23,7 @@ import { isTypeDefinitionNode, ArgumentNode, typeFromAST, + Source as GraphQLSource, } from 'graphql'; import { @@ -47,6 +49,7 @@ import { getTypeInfo, DefinitionQueryResponse, getDefinitionQueryResultForArgument, + IRange, } from 'graphql-language-service'; import type { GraphQLCache } from './GraphQLCache'; @@ -359,8 +362,13 @@ export class GraphQLLanguageService { public async getDocumentSymbols( document: string, filePath: Uri, + fileDocumentRange?: IRange | null, ): Promise { - const outline = await this.getOutline(document); + const outline = await this.getOutline( + document, + path.basename(filePath), + fileDocumentRange?.start, + ); if (!outline) { return []; } @@ -379,14 +387,12 @@ export class GraphQLLanguageService { } output.push({ - // @ts-ignore name: tree.representativeName ?? 'Anonymous', kind: getKind(tree), location: { uri: filePath, range: { start: tree.startPosition, - // @ts-ignore end: tree.endPosition, }, }, @@ -539,7 +545,20 @@ export class GraphQLLanguageService { ); } - async getOutline(documentText: string): Promise { - return getOutline(documentText); + async getOutline( + documentText: string, + documentName: string, + documentOffset?: IPosition, + ): Promise { + return getOutline( + new GraphQLSource( + documentText, + documentName, + documentOffset && { + column: documentOffset.character + 1, + line: documentOffset.line + 1, + }, + ), + ); } } diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index 8872eaa602..a779f967a8 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -66,7 +66,12 @@ import { LoaderNoResultError, ProjectNotFoundError, } from 'graphql-config'; -import type { LoadConfigOptions, LocateCommand } from './types'; +import type { + LoadConfigOptions, + LocateCommand, + VSCodeGraphQLConfigLoadSettings, + VSCodeGraphQLSettings, +} from './types'; import { DEFAULT_SUPPORTED_EXTENSIONS, SupportedExtensionsEnum, @@ -83,12 +88,20 @@ const configDocLink = type CachedDocumentType = { version: number; contents: CachedContent[]; + size: number; }; function toPosition(position: VscodePosition): IPosition { return new Position(position.line, position.character); } +interface MessageProcessorSettings extends VSCodeGraphQLSettings { + load: VSCodeGraphQLConfigLoadSettings & { + fileName?: string; + [key: string]: unknown; + }; +} + export class MessageProcessor { private _connection: Connection; private _graphQLCache!: GraphQLCache; @@ -103,7 +116,7 @@ export class MessageProcessor { private _tmpDirBase: string; private _loadConfigOptions: LoadConfigOptions; private _rootPath: string = process.cwd(); - private _settings: any; + private _settings: MessageProcessorSettings = { load: {} }; private _providedConfig?: GraphQLConfig; constructor({ @@ -210,7 +223,7 @@ export class MessageProcessor { // TODO: eventually we will instantiate an instance of this per workspace, // so rootDir should become that workspace's rootDir this._settings = { ...settings, ...vscodeSettings }; - const rootDir = this._settings?.load?.rootDir.length + const rootDir = this._settings?.load?.rootDir?.length ? this._settings?.load?.rootDir : this._rootPath; if (settings?.dotEnvPath) { @@ -486,17 +499,11 @@ export class MessageProcessor { // As `contentChanges` is an array, and we just want the // latest update to the text, grab the last entry from the array. - - // If it's a .js file, try parsing the contents to see if GraphQL queries - // exist. If not found, delete from the cache. const { contents } = await this._parseAndCacheFile( uri, project, contentChanges.at(-1)!.text, ); - // // If it's a .graphql file, proceed normally and invalidate the cache. - // await this._invalidateCache(textDocument, uri, contents); - const diagnostics: Diagnostic[] = []; if (project?.extensions?.languageService?.enableValidation !== false) { @@ -706,7 +713,10 @@ export class MessageProcessor { const contents = await this._parser(fileText, uri); const cachedDocument = this._textDocumentCache.get(uri); const version = cachedDocument ? cachedDocument.version++ : 0; - await this._invalidateCache({ uri, version }, uri, contents); + await this._invalidateCache( + { uri, version }, + { contents, size: fileText.length }, + ); await this._updateFragmentDefinition(uri, contents); await this._updateObjectTypeDefinition(uri, contents, project); await this._updateSchemaIfChanged(project, uri); @@ -942,14 +952,13 @@ export class MessageProcessor { const { textDocument } = params; const cachedDocument = this._getCachedDocument(textDocument.uri); - if (!cachedDocument?.contents[0]) { + if (!cachedDocument?.contents?.length) { return []; } if ( this._settings.largeFileThreshold !== undefined && - this._settings.largeFileThreshold < - cachedDocument.contents[0].query.length + this._settings.largeFileThreshold < cachedDocument.size ) { return []; } @@ -962,10 +971,16 @@ export class MessageProcessor { }), ); - return this._languageService.getDocumentSymbols( - cachedDocument.contents[0].query, - textDocument.uri, + const results = await Promise.all( + cachedDocument.contents.map(content => + this._languageService.getDocumentSymbols( + content.query, + textDocument.uri, + content.range, + ), + ), ); + return results.flat(); } // async handleReferencesRequest(params: ReferenceParams): Promise { @@ -1003,14 +1018,25 @@ export class MessageProcessor { documents.map(async ([uri]) => { const cachedDocument = this._getCachedDocument(uri); - if (!cachedDocument) { + if (!cachedDocument?.contents?.length) { return []; } - const docSymbols = await this._languageService.getDocumentSymbols( - cachedDocument.contents[0].query, - uri, + if ( + this._settings.largeFileThreshold !== undefined && + this._settings.largeFileThreshold < cachedDocument.size + ) { + return []; + } + const docSymbols = await Promise.all( + cachedDocument.contents.map(content => + this._languageService.getDocumentSymbols( + content.query, + uri, + content.range, + ), + ), ); - symbols.push(...docSymbols); + symbols.push(...docSymbols.flat()); }), ); return symbols.filter(symbol => symbol?.name?.includes(params.query)); @@ -1032,7 +1058,10 @@ export class MessageProcessor { try { const contents = await this._parser(text, uri); if (contents.length > 0) { - await this._invalidateCache({ version, uri }, uri, contents); + await this._invalidateCache( + { version, uri }, + { contents, size: text.length }, + ); await this._updateObjectTypeDefinition(uri, contents, project); } } catch (err) { @@ -1248,7 +1277,10 @@ export class MessageProcessor { await this._updateObjectTypeDefinition(uri, contents); await this._updateFragmentDefinition(uri, contents); - await this._invalidateCache({ version: 1, uri }, uri, contents); + await this._invalidateCache( + { version: 1, uri }, + { contents, size: document.rawSDL.length }, + ); }), ); } catch (err) { @@ -1357,27 +1389,20 @@ export class MessageProcessor { } private async _invalidateCache( textDocument: VersionedTextDocumentIdentifier, - uri: Uri, - contents: CachedContent[], + meta: Omit, ): Promise | null> { + const { uri, version } = textDocument; if (this._textDocumentCache.has(uri)) { const cachedDocument = this._textDocumentCache.get(uri); - if ( - cachedDocument && - textDocument?.version && - cachedDocument.version < textDocument.version - ) { + if (cachedDocument && version && cachedDocument.version < version) { // Current server capabilities specify the full sync of the contents. // Therefore always overwrite the entire content. - return this._textDocumentCache.set(uri, { - version: textDocument.version, - contents, - }); + return this._textDocumentCache.set(uri, { ...meta, version }); } } return this._textDocumentCache.set(uri, { - version: textDocument.version ?? 0, - contents, + ...meta, + version: version ?? 0, }); } } diff --git a/packages/graphql-language-service-server/src/parseDocument.ts b/packages/graphql-language-service-server/src/parseDocument.ts index 2fa003df56..d8fc78fe05 100644 --- a/packages/graphql-language-service-server/src/parseDocument.ts +++ b/packages/graphql-language-service-server/src/parseDocument.ts @@ -34,10 +34,12 @@ export async function parseDocument( return []; } + // If it's a .js file, parse the contents to see if GraphQL queries exist. if (fileExtensions.includes(ext)) { const templates = await findGraphQLTags(text, ext, uri, logger); return templates.map(({ template, range }) => ({ query: template, range })); } + // If it's a .graphql file, use the entire file if (graphQLFileExtensions.includes(ext)) { const query = text; const lines = query.split('\n'); diff --git a/packages/graphql-language-service-server/src/types.ts b/packages/graphql-language-service-server/src/types.ts index cc3c604412..9ffebfefac 100644 --- a/packages/graphql-language-service-server/src/types.ts +++ b/packages/graphql-language-service-server/src/types.ts @@ -115,3 +115,45 @@ export interface ServerOptions { */ debug?: true; } + +export interface VSCodeGraphQLSettings { + /** + * Enable debug logs and node debugger for client + */ + debug?: boolean | null; + /** + * Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Enabled by default when one or more schema entry is not a local file with SDL in it. Disable if you want to use SDL with a generated schema. + */ + cacheSchemaFileForLookup?: boolean; + /** + * Disables outlining and other expensive operations for files larger than this threshold (in bytes). Defaults to 1000000 (one million). + */ + largeFileThreshold?: number; + /** + * Fail the request on invalid certificate + */ + rejectUnauthorized?: boolean; + /** + * Schema cache ttl in milliseconds - the interval before requesting a fresh schema when caching the local schema file is enabled. Defaults to 30000 (30 seconds). + */ + schemaCacheTTL?: number; +} + +export interface VSCodeGraphQLConfigLoadSettings { + /** + * Base dir for graphql config loadConfig(), to look for config files or package.json + */ + rootDir?: string; + /** + * exact filePath for a `graphql-config` file `loadConfig()` + */ + filePath?: string; + /** + * optional .config.{js,ts,toml,yaml,json} & rc* instead of default `graphql` + */ + configName?: string; + /** + * legacy mode for graphql config v2 config + */ + legacy?: boolean; +} diff --git a/packages/graphql-language-service/src/interface/getDefinition.ts b/packages/graphql-language-service/src/interface/getDefinition.ts index 55f28762eb..c62affa603 100644 --- a/packages/graphql-language-service/src/interface/getDefinition.ts +++ b/packages/graphql-language-service/src/interface/getDefinition.ts @@ -25,7 +25,7 @@ import { import { Definition, FragmentInfo, Uri, ObjectTypeInfo } from '../types'; -import { locToRange, offsetToPosition, Range, Position } from '../utils'; +import { locToRange, locStartToPosition, Range, Position } from '../utils'; // import { getTypeInfo } from './getAutocompleteSuggestions'; export type DefinitionQueryResult = { @@ -56,7 +56,7 @@ function getRange(text: string, node: ASTNode): Range { function getPosition(text: string, node: ASTNode): Position { const location = node.loc!; assert(location, 'Expected ASTNode to have a location.'); - return offsetToPosition(text, location.start); + return locStartToPosition(text, location); } export async function getDefinitionQueryResultForNamedType( diff --git a/packages/graphql-language-service/src/interface/getOutline.ts b/packages/graphql-language-service/src/interface/getOutline.ts index c8841d5796..136170a0ad 100644 --- a/packages/graphql-language-service/src/interface/getOutline.ts +++ b/packages/graphql-language-service/src/interface/getOutline.ts @@ -13,13 +13,16 @@ import { TokenKind, IPosition, OutlineTree, + IRange, } from '../types'; import { Kind, parse, visit, + ASTNode, FieldNode, + ArgumentNode, InlineFragmentNode, DocumentNode, FragmentSpreadNode, @@ -35,9 +38,12 @@ import { InputValueDefinitionNode, FieldDefinitionNode, EnumValueDefinitionNode, + InputObjectTypeDefinitionNode, + Source as GraphQLSource, } from 'graphql'; +import type { ASTReducer } from 'graphql/language/visitor'; -import { offsetToPosition } from '../utils'; +import { locToRange } from '../utils'; const { INLINE_FRAGMENT } = Kind; @@ -59,58 +65,87 @@ const OUTLINEABLE_KINDS = { FieldDefinition: true, }; -export type OutlineableKinds = keyof typeof OUTLINEABLE_KINDS; +type LiteralToEnum = Enum extends never + ? never + : { 0: Enum }[Enum extends Literal ? 0 : never]; -// type OutlineableNodes = FieldNode | OperationDefinitionNode | DocumentNode | SelectionSetNode | NameNode | FragmentDefinitionNode | FragmentSpreadNode |InlineFragmentNode | ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode +export type OutlineableKinds = LiteralToEnum< + keyof typeof OUTLINEABLE_KINDS, + Kind +>; + +type OutlineableNode = Extract; +type AllKeys = T extends unknown ? keyof T : never; +type ExclusiveUnion> = T extends never + ? never + : T & Partial, never>>; + +type OutlineTreeResultMeta = { + representativeName?: string | NameNode; + startPosition: IPosition; + endPosition: IPosition; + kind: OutlineableKinds; + children: + | SelectionSetNode + | readonly ArgumentNode[] + | readonly FieldDefinitionNode[] + | readonly EnumValueDefinitionNode[] + | readonly InputValueDefinitionNode[]; +}; type OutlineTreeResult = - | { - representativeName: string; - startPosition: IPosition; - endPosition: IPosition; - children: SelectionSetNode[] | []; - tokenizedText: TextToken[]; - } + | (OutlineTreeResultMeta & { tokenizedText: TextToken[] }) | string | readonly DefinitionNode[] | readonly SelectionNode[] | FieldNode[] | SelectionSetNode; -type OutlineTreeConverterType = Partial<{ - [key in OutlineableKinds]: (node: any) => OutlineTreeResult; -}>; +type OutlineTreeConverterType = { + [key in OutlineableKinds]: ( + node: Extract, + ) => OutlineTreeResult; +}; -export function getOutline(documentText: string): Outline | null { +export function getOutline(document: string | GraphQLSource): Outline | null { let ast; try { - ast = parse(documentText); + ast = parse(document); } catch { return null; } - const visitorFns = outlineTreeConverter(documentText); + type VisitorFns = Record OutlineTreeResult>; + const visitorFns = outlineTreeConverter(document) as VisitorFns; const outlineTrees = visit(ast, { - leave(node) { - if (visitorFns !== undefined && node.kind in visitorFns) { - // @ts-ignore + leave(node: ASTNode) { + if (node.kind in visitorFns) { return visitorFns[node.kind](node); } return null; }, - }) as unknown as OutlineTree[]; + } as ASTReducer) as OutlineTree[]; return { outlineTrees }; } -function outlineTreeConverter(docText: string): OutlineTreeConverterType { - // TODO: couldn't find a type that would work for all cases here, - // however the inference is not broken by this at least - const meta = (node: any) => { +function outlineTreeConverter( + document: string | GraphQLSource, +): OutlineTreeConverterType { + const docText = typeof document === 'string' ? document : document.body; + const { locationOffset }: Partial = + typeof document === 'string' ? {} : document; + type MetaNode = Exclude< + OutlineableNode, + DocumentNode | SelectionSetNode | NameNode | InlineFragmentNode + >; + const meta = (node: ExclusiveUnion): OutlineTreeResultMeta => { + const range = locToRange(docText, node.loc!); + applyOffsetToRange(range, locationOffset); return { representativeName: node.name, - startPosition: offsetToPosition(docText, node.loc.start), - endPosition: offsetToPosition(docText, node.loc.end), + startPosition: range.start, + endPosition: range.end, kind: node.kind, children: node.selectionSet || node.fields || node.values || node.arguments || [], @@ -176,7 +211,7 @@ function outlineTreeConverter(docText: string): OutlineTreeConverterType { ], ...meta(node), }), - InputObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => ({ + InputObjectTypeDefinition: (node: InputObjectTypeDefinitionNode) => ({ tokenizedText: [ buildToken('keyword', 'input'), buildToken('whitespace', ' '), @@ -223,3 +258,24 @@ function concatMap(arr: Readonly, fn: Function): Readonly { } return res; } + +function applyOffsetToRange( + range: IRange, + locationOffset?: GraphQLSource['locationOffset'], +) { + if (!locationOffset) { + return; + } + applyOffsetToPosition(range.start, locationOffset); + applyOffsetToPosition(range.end, locationOffset); +} + +function applyOffsetToPosition( + position: IPosition, + locationOffset: GraphQLSource['locationOffset'], +) { + if (position.line === 1) { + position.character += locationOffset.column - 1; + } + position.line += locationOffset.line - 1; +} diff --git a/packages/graphql-language-service/src/types.ts b/packages/graphql-language-service/src/types.ts index 52b77d843f..58c968180b 100644 --- a/packages/graphql-language-service/src/types.ts +++ b/packages/graphql-language-service/src/types.ts @@ -217,7 +217,7 @@ export type OutlineTree = { representativeName?: string; kind: string; startPosition: IPosition; - endPosition?: IPosition; + endPosition: IPosition; children: OutlineTree[]; }; diff --git a/packages/graphql-language-service/src/utils/Range.ts b/packages/graphql-language-service/src/utils/Range.ts index f118aaa34e..80710dc5cd 100644 --- a/packages/graphql-language-service/src/utils/Range.ts +++ b/packages/graphql-language-service/src/utils/Range.ts @@ -66,8 +66,37 @@ export function offsetToPosition(text: string, loc: number): Position { return new Position(lines, loc - lastLineIndex - 1); } +export function locStartToPosition(text: string, loc: Location) { + return loc.startToken?.start === loc.start + ? new Position(loc.startToken.line - 1, loc.startToken.column - 1) + : offsetToPosition(text, loc.start); +} + +function locEndToPosition(text: string, loc: Location) { + if (loc.startToken?.start !== loc.start) { + return offsetToPosition(text, loc.end); + } + const EOL = '\n'; + const buf = text.slice(loc.endToken.start, loc.endToken.end); + const lastLineIndex = buf.lastIndexOf(EOL); + if (lastLineIndex === -1) { + return new Position( + loc.endToken.line - 1, + loc.endToken.column + buf.length - 1, + ); + } + const lines = buf.split(EOL).length - 1; + return new Position( + loc.endToken.line - 1 + lines, + loc.endToken.end - loc.endToken.start - lastLineIndex - 1, + ); +} + export function locToRange(text: string, loc: Location): Range { - const start = offsetToPosition(text, loc.start); - const end = offsetToPosition(text, loc.end); + const start = locStartToPosition(text, loc); + const end = + loc.endToken?.end === loc.end + ? locEndToPosition(text, loc) + : offsetToPosition(text, loc.end); return new Range(start, end); } diff --git a/packages/graphql-language-service/src/utils/__tests__/Range.test.ts b/packages/graphql-language-service/src/utils/__tests__/Range.test.ts index de1238ba84..a0da81339a 100644 --- a/packages/graphql-language-service/src/utils/__tests__/Range.test.ts +++ b/packages/graphql-language-service/src/utils/__tests__/Range.test.ts @@ -7,16 +7,18 @@ * */ -import { Location } from 'graphql'; +import { Location, parse, getOperationAST, FieldNode } from 'graphql'; import { Range, Position, offsetToPosition, locToRange } from '../Range'; const text = `query test { name }`; +const parsed = parse(text); +const nameNode = getOperationAST(parsed)!.selectionSet.selections[0]; const absRange: Location = { start: 15, - end: 18, + end: 19, // @ts-ignore startToken: null, // @ts-ignore @@ -26,7 +28,7 @@ const absRange: Location = { }; // position of 'name' attribute in the test query const offsetRangeStart = new Position(1, 2); -const offsetRangeEnd = new Position(1, 5); +const offsetRangeEnd = new Position(1, 6); describe('Position', () => { it('constructs a IPosition object', () => { @@ -87,4 +89,31 @@ describe('locToRange()', () => { expect(range.end.character).toEqual(offsetRangeEnd.character); expect(range.end.line).toEqual(offsetRangeEnd.line); }); + it('returns the range for a location from a parsed node', () => { + const range = locToRange(text, nameNode.loc!); + expect(range.start.character).toEqual(offsetRangeStart.character); + expect(range.start.line).toEqual(offsetRangeStart.line); + expect(range.end.character).toEqual(offsetRangeEnd.character); + expect(range.end.line).toEqual(offsetRangeEnd.line); + }); + it('returns the same range as offsetToPosition with multiline token', () => { + const blockText = `mutation test { + saveMarkdown(markdown: """ + * block + * multiline + * string + """) + }`; + const blockParsed = parse(blockText); + const fieldNode = getOperationAST(blockParsed)!.selectionSet + .selections[0] as FieldNode; + const argumentNode = fieldNode.arguments![0]; + const startPosition = offsetToPosition(blockText, argumentNode.loc!.start); + const endPosition = offsetToPosition(blockText, argumentNode.loc!.end); + const range = locToRange(blockText, argumentNode.loc!); + expect(range.start.character).toEqual(startPosition.character); + expect(range.start.line).toEqual(startPosition.line); + expect(range.end.character).toEqual(endPosition.character); + expect(range.end.line).toEqual(endPosition.line); + }); }); diff --git a/packages/graphql-language-service/src/utils/index.ts b/packages/graphql-language-service/src/utils/index.ts index 027182ad88..96ee8e6280 100644 --- a/packages/graphql-language-service/src/utils/index.ts +++ b/packages/graphql-language-service/src/utils/index.ts @@ -21,7 +21,13 @@ export { export { getASTNodeAtPosition, pointToOffset } from './getASTNodeAtPosition'; -export { Position, Range, locToRange, offsetToPosition } from './Range'; +export { + Position, + Range, + locStartToPosition, + locToRange, + offsetToPosition, +} from './Range'; export { validateWithCustomRules } from './validateWithCustomRules'; diff --git a/resources/custom-words.txt b/resources/custom-words.txt index 8ab1dc62ea..96a92765a2 100644 --- a/resources/custom-words.txt +++ b/resources/custom-words.txt @@ -55,6 +55,7 @@ cshaver dedenting delivr devx +dimaMachina dhanani dima dirpath