Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.

avoid giving diagnostics for stuff falling out of the node-range (#73)

* avoid giving diagnostics for stuff falling out of the node-range

* calculate the spans we are adding

* fix bugs

* proper annotations

Changed files
+205 -105
.changeset
packages
+5
.changeset/hungry-hairs-draw.md
···
···
+
---
+
'@0no-co/graphqlsp': patch
+
---
+
+
Avoid polluting with diagnostics not in current file
+93 -42
packages/graphqlsp/src/ast/resolve.ts
···
import { findNode } from '.';
import { getSource } from '../ast';
export function resolveTemplate(
node: TaggedTemplateExpression,
filename: string,
info: ts.server.PluginCreateInfo
-
): string {
let templateText = node.template.getText().slice(1, -1);
if (
isNoSubstitutionTemplateLiteral(node.template) ||
node.template.templateSpans.length === 0
) {
-
return templateText;
}
-
node.template.templateSpans.forEach(span => {
-
if (isIdentifier(span.expression)) {
-
const definitions = info.languageService.getDefinitionAtPosition(
-
filename,
-
span.expression.getStart()
-
);
-
if (!definitions) return;
-
const def = definitions[0];
-
const src = getSource(info, def.fileName);
-
if (!src) return;
-
const node = findNode(src, def.textSpan.start);
-
if (!node || !node.parent) return;
-
const parent = node.parent;
-
if (ts.isVariableDeclaration(parent)) {
-
if (
-
parent.initializer &&
-
isTaggedTemplateExpression(parent.initializer)
-
) {
-
const text = resolveTemplate(parent.initializer, def.fileName, info);
-
templateText = templateText.replace(
-
'${' + span.expression.escapedText + '}',
-
text
-
);
-
} else if (
-
parent.initializer &&
-
isAsExpression(parent.initializer) &&
-
isTaggedTemplateExpression(parent.initializer.expression)
-
) {
-
const text = resolveTemplate(
-
parent.initializer.expression,
-
def.fileName,
-
info
-
);
-
templateText = templateText.replace(
-
'${' + span.expression.escapedText + '}',
-
text
-
);
}
}
-
}
-
});
-
return templateText;
}
···
import { findNode } from '.';
import { getSource } from '../ast';
+
type TemplateResult = {
+
combinedText: string;
+
resolvedSpans: Array<{
+
identifier: string;
+
original: { start: number; length: number };
+
new: { start: number; length: number };
+
}>;
+
};
+
export function resolveTemplate(
node: TaggedTemplateExpression,
filename: string,
info: ts.server.PluginCreateInfo
+
): TemplateResult {
let templateText = node.template.getText().slice(1, -1);
if (
isNoSubstitutionTemplateLiteral(node.template) ||
node.template.templateSpans.length === 0
) {
+
return { combinedText: templateText, resolvedSpans: [] };
}
+
let addedCharacters = 0;
+
const resolvedSpans = node.template.templateSpans
+
.map(span => {
+
if (isIdentifier(span.expression)) {
+
const definitions = info.languageService.getDefinitionAtPosition(
+
filename,
+
span.expression.getStart()
+
);
+
if (!definitions) return;
+
+
const def = definitions[0];
+
const src = getSource(info, def.fileName);
+
if (!src) return;
+
+
const node = findNode(src, def.textSpan.start);
+
if (!node || !node.parent) return;
+
+
const parent = node.parent;
+
if (ts.isVariableDeclaration(parent)) {
+
const identifierName = span.expression.escapedText;
+
// we reduce by two to account for the "${"
+
const originalStart = span.expression.getStart() - 2;
+
const originalRange = {
+
start: originalStart,
+
// we add 1 to account for the "}"
+
length: span.expression.end - originalStart + 1,
+
};
+
if (
+
parent.initializer &&
+
isTaggedTemplateExpression(parent.initializer)
+
) {
+
const text = resolveTemplate(
+
parent.initializer,
+
def.fileName,
+
info
+
);
+
templateText = templateText.replace(
+
'${' + span.expression.escapedText + '}',
+
text.combinedText
+
);
+
+
const alteredSpan = {
+
identifier: identifierName,
+
original: originalRange,
+
new: {
+
start: originalRange.start + addedCharacters,
+
length: text.combinedText.length,
+
},
+
};
+
addedCharacters += text.combinedText.length - originalRange.length;
+
return alteredSpan;
+
} else if (
+
parent.initializer &&
+
isAsExpression(parent.initializer) &&
+
isTaggedTemplateExpression(parent.initializer.expression)
+
) {
+
const text = resolveTemplate(
+
parent.initializer.expression,
+
def.fileName,
+
info
+
);
+
templateText = templateText.replace(
+
'${' + span.expression.escapedText + '}',
+
text.combinedText
+
);
+
const alteredSpan = {
+
identifier: identifierName,
+
original: originalRange,
+
new: {
+
start: originalRange.start + addedCharacters,
+
length: text.combinedText.length,
+
},
+
};
+
addedCharacters += text.combinedText.length - originalRange.length;
+
return alteredSpan;
+
}
+
return undefined;
}
}
+
return undefined;
+
})
+
.filter(Boolean) as TemplateResult['resolvedSpans'];
+
+
return { combinedText: templateText, resolvedSpans };
}
+1 -1
packages/graphqlsp/src/autoComplete.ts
···
const foundToken = getToken(template, cursorPosition);
if (!foundToken || !schema.current) return undefined;
-
const text = resolveTemplate(node, filename, info);
const cursor = new Cursor(foundToken.line, foundToken.start);
···
const foundToken = getToken(template, cursorPosition);
if (!foundToken || !schema.current) return undefined;
+
const text = resolveTemplate(node, filename, info).combinedText;
const cursor = new Cursor(foundToken.line, foundToken.start);
+62 -20
packages/graphqlsp/src/diagnostics.ts
···
schema: { current: GraphQLSchema | null },
info: ts.server.PluginCreateInfo
): ts.Diagnostic[] | undefined {
const disableTypegen = info.config.disableTypegen;
const tagTemplate = info.config.template || 'gql';
const scalars = info.config.scalars || {};
···
}
}
-
return resolveTemplate(node, filename, info);
});
const diagnostics = nodes
···
}
}
-
const text = resolveTemplate(node, filename, info);
const lines = text.split('\n');
let startingPosition = node.pos + (tagTemplate.length + 1);
-
const graphQLDiagnostics = getDiagnostics(text, schema.current).map(x => {
-
const { start, end } = x.range;
-
// We add the start.line to account for newline characters which are
-
// split out
-
let startChar = startingPosition + start.line;
-
for (let i = 0; i <= start.line; i++) {
-
if (i === start.line) startChar += start.character;
-
else startChar += lines[i].length;
-
}
-
let endChar = startingPosition + end.line;
-
for (let i = 0; i <= end.line; i++) {
-
if (i === end.line) endChar += end.character;
-
else endChar += lines[i].length;
-
}
-
// We add 1 to the start because the range is exclusive of start.character
-
return { ...x, start: startChar + 1, length: endChar - startChar };
-
});
try {
const parsed = parse(text, { noLocation: true });
···
node,
node.getSourceFile().fileName,
info
-
);
try {
const parsed = parse(text, { noLocation: true });
if (
···
schema: { current: GraphQLSchema | null },
info: ts.server.PluginCreateInfo
): ts.Diagnostic[] | undefined {
+
const logger = (msg: string) =>
+
info.project.projectService.logger.info(`[GraphQLSP] ${msg}`);
const disableTypegen = info.config.disableTypegen;
const tagTemplate = info.config.template || 'gql';
const scalars = info.config.scalars || {};
···
}
}
+
return resolveTemplate(node, filename, info).combinedText;
});
const diagnostics = nodes
···
}
}
+
const { combinedText: text, resolvedSpans } = resolveTemplate(
+
node,
+
filename,
+
info
+
);
const lines = text.split('\n');
let startingPosition = node.pos + (tagTemplate.length + 1);
+
const endPosition = startingPosition + node.getText().length;
+
const graphQLDiagnostics = getDiagnostics(text, schema.current)
+
.map(x => {
+
const { start, end } = x.range;
+
// We add the start.line to account for newline characters which are
+
// split out
+
let startChar = startingPosition + start.line;
+
for (let i = 0; i <= start.line; i++) {
+
if (i === start.line) startChar += start.character;
+
else startChar += lines[i].length;
+
}
+
let endChar = startingPosition + end.line;
+
for (let i = 0; i <= end.line; i++) {
+
if (i === end.line) endChar += end.character;
+
else endChar += lines[i].length;
+
}
+
const locatedInFragment = resolvedSpans.find(x => {
+
const newEnd = x.new.start + x.new.length;
+
return startChar >= x.new.start && endChar <= newEnd;
+
});
+
+
if (!!locatedInFragment) {
+
return {
+
...x,
+
start: locatedInFragment.original.start,
+
length: locatedInFragment.original.length,
+
};
+
} else {
+
if (startChar > endPosition) {
+
// we have to calculate the added length and fix this
+
const addedCharacters = resolvedSpans
+
.filter(x => x.new.start + x.new.length < startChar)
+
.reduce(
+
(acc, span) => acc + (span.new.length - span.original.length),
+
0
+
);
+
startChar = startChar - addedCharacters;
+
endChar = endChar - addedCharacters;
+
return {
+
...x,
+
start: startChar + 1,
+
length: endChar - startChar,
+
};
+
} else {
+
return {
+
...x,
+
start: startChar + 1,
+
length: endChar - startChar,
+
};
+
}
+
}
+
})
+
.filter(x => x.start + x.length <= endPosition);
try {
const parsed = parse(text, { noLocation: true });
···
node,
node.getSourceFile().fileName,
info
+
).combinedText;
try {
const parsed = parse(text, { noLocation: true });
if (
+43 -41
packages/graphqlsp/src/graphql/generateTypes.ts
···
scalars: Record<string, unknown>,
baseTypesPath: string
) => {
-
if (!schema) return;
-
const parts = outputFile.split('/');
-
parts.pop();
-
let basePath = path
-
.relative(parts.join('/'), baseTypesPath)
-
.replace('.ts', '');
-
// case where files are declared globally, we need to prefix with ./
-
if (basePath === '__generated__/baseGraphQLSP') {
-
basePath = './' + basePath;
-
}
-
const config = {
-
documents: [
-
{
-
location: 'operation.graphql',
-
document: parse(doc),
},
-
],
-
config: {
-
scalars,
-
avoidOptionals: false,
-
enumsAsTypes: true,
-
nonOptionalTypename: true,
-
namespacedImportName: 'Types',
-
},
-
filename: outputFile,
-
schema: parse(printSchema(schema)),
-
plugins: [
-
{ 'typescript-operations': {} },
-
{ 'typed-document-node': {} },
-
{ add: { content: `import * as Types from "${basePath}"` } },
-
],
-
pluginMap: {
-
'typescript-operations': typescriptOperationsPlugin,
-
'typed-document-node': typedDocumentNodePlugin,
-
add: addPlugin,
-
},
-
};
-
// @ts-ignore
-
const output = await codegen(config);
-
fs.writeFile(path.join(outputFile), output, 'utf8', err => {
-
console.error(err);
-
});
};
···
scalars: Record<string, unknown>,
baseTypesPath: string
) => {
+
try {
+
if (!schema) return;
+
const parts = outputFile.split('/');
+
parts.pop();
+
let basePath = path
+
.relative(parts.join('/'), baseTypesPath)
+
.replace('.ts', '');
+
// case where files are declared globally, we need to prefix with ./
+
if (basePath === '__generated__/baseGraphQLSP') {
+
basePath = './' + basePath;
+
}
+
const config = {
+
documents: [
+
{
+
location: 'operation.graphql',
+
document: parse(doc),
+
},
+
],
+
config: {
+
scalars,
+
avoidOptionals: false,
+
enumsAsTypes: true,
+
nonOptionalTypename: true,
+
namespacedImportName: 'Types',
},
+
filename: outputFile,
+
schema: parse(printSchema(schema)),
+
plugins: [
+
{ 'typescript-operations': {} },
+
{ 'typed-document-node': {} },
+
{ add: { content: `import * as Types from "${basePath}"` } },
+
],
+
pluginMap: {
+
'typescript-operations': typescriptOperationsPlugin,
+
'typed-document-node': typedDocumentNodePlugin,
+
add: addPlugin,
+
},
+
};
+
// @ts-ignore
+
const output = await codegen(config);
+
fs.writeFile(path.join(outputFile), output, 'utf8', err => {
+
console.error(err);
+
});
+
} catch (e) {}
};
+1 -1
packages/graphqlsp/src/quickInfo.ts
···
const hoverInfo = getHoverInformation(
schema.current,
-
resolveTemplate(node, filename, info),
new Cursor(foundToken.line, foundToken.start)
);
···
const hoverInfo = getHoverInformation(
schema.current,
+
resolveTemplate(node, filename, info).combinedText,
new Cursor(foundToken.line, foundToken.start)
);