Skip to content

Commit a9c67d4

Browse files
committed
fix(apidom-ls): fix completion and validation edge cases
1 parent 3540870 commit a9c67d4

File tree

11 files changed

+366
-43
lines changed

11 files changed

+366
-43
lines changed

packages/apidom-ls/src/document-cache.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,24 @@ import { TextDocument } from 'vscode-languageserver-textdocument';
44
Adapted from https://github.com/microsoft/vscode/blob/main/extensions/json-language-features/server/src/languageModelCache.ts
55
*/
66
export interface DocumentCache<T> {
7-
get(document: TextDocument): Promise<T | undefined>;
7+
get(document: TextDocument, text?: string): Promise<T | undefined>;
88
onDocumentRemoved(document: TextDocument): void;
99
dispose(): void;
1010
}
1111

1212
export function getDocumentCache<T>(
1313
maxEntries: number,
1414
cleanupIntervalTimeInSec: number,
15-
parse: (document: TextDocument) => Promise<T>,
15+
parse: (document: TextDocument | string) => Promise<T>,
1616
): DocumentCache<T> {
17+
// TODO possibly better comparison on processedText length or other cheap comparison
1718
let documents: {
1819
[uri: string]: {
1920
version: number;
2021
languageId: string;
2122
cTime: number;
2223
parsedDocument: T;
24+
processedText?: string;
2325
};
2426
} = {};
2527
let nModels = 0;
@@ -41,24 +43,26 @@ export function getDocumentCache<T>(
4143
}
4244

4345
return {
44-
async get(document: TextDocument): Promise<T> {
46+
async get(document: TextDocument, text?: string): Promise<T> {
4547
const { version } = document;
4648
const { languageId } = document;
4749
const documentInfo = documents[document.uri];
4850
if (
4951
documentInfo &&
5052
documentInfo.version === version &&
51-
documentInfo.languageId === languageId
53+
documentInfo.languageId === languageId &&
54+
(!text || documentInfo.processedText === text)
5255
) {
5356
documentInfo.cTime = Date.now();
5457
return documentInfo.parsedDocument;
5558
}
56-
const parsedDocument = await parse(document);
59+
const parsedDocument = await parse(text || document);
5760
documents[document.uri] = {
5861
parsedDocument,
5962
version,
6063
languageId,
6164
cTime: Date.now(),
65+
processedText: text,
6266
};
6367
if (!documentInfo) {
6468
// eslint-disable-next-line no-plusplus

packages/apidom-ls/src/parser-factory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function isJsonDoc(document: TextDocument | string): boolean {
5252
return jsonStart != null && JSON_ENDS[jsonStart[0]].test(text);
5353
}
5454

55-
export function getParser(document: TextDocument): ApiDOMParser {
55+
export function getParser(document: TextDocument | string): ApiDOMParser {
5656
const async = isAsyncDoc(document);
5757
const json = isJsonDoc(document);
5858
if (async && json) {
@@ -71,11 +71,11 @@ export function getParser(document: TextDocument): ApiDOMParser {
7171
}
7272

7373
export async function parse(
74-
textDocument: TextDocument,
74+
textDocument: TextDocument | string,
7575
metadataMaps: MetadataMaps | undefined,
7676
): Promise<ParseResultElement> {
7777
// TODO improve detection mechanism
78-
const text: string = textDocument.getText();
78+
const text: string = typeof textDocument === 'string' ? textDocument : textDocument.getText();
7979
const async = isAsyncDoc(textDocument);
8080
const json = isJsonDoc(textDocument);
8181
let result;

packages/apidom-ls/src/services/completion/completion-service.ts

Lines changed: 117 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,55 @@ export class DefaultCompletionService implements CompletionService {
228228
endArrayNodeChar = ']'; // eslint-disable-line @typescript-eslint/no-unused-vars
229229
}
230230

231-
const result = await this.settings?.documentCache?.get(textDocument);
231+
const offset = textDocument.offsetAt(position);
232+
233+
/*
234+
process errored YAML input badly handled by YAML parser (see https://github.com/swagger-api/apidom/issues/194)
235+
similarly to what done in swagger-editor: check if we are in a partial "prefix" scenario, in this case add a `:`
236+
to the line and parse that line instead.
237+
238+
```
239+
info:
240+
foo<caret here>
241+
..
242+
```
243+
244+
*/
245+
let processedText;
246+
if (!isJson) {
247+
const lineContentRange = DefaultCompletionService.getNonEmptyContentRange(
248+
textDocument,
249+
offset,
250+
);
251+
const lineNonEmptyContent = lineContentRange ? textDocument.getText(lineContentRange) : '';
252+
const lineContent = lineContentRange
253+
? DefaultCompletionService.getLine(textDocument, offset)
254+
: '';
255+
const lineIndent = DefaultCompletionService.getIndentation(lineContent);
256+
257+
const prevLineOffset = DefaultCompletionService.getPreviousLineOffset(textDocument, offset);
258+
const prevLineContent = DefaultCompletionService.getLine(textDocument, prevLineOffset);
259+
const prevIndent = DefaultCompletionService.getIndentation(prevLineContent);
260+
const nextLineOffset = DefaultCompletionService.getNextLineOffset(textDocument, offset);
261+
const nextLineContent = DefaultCompletionService.getLine(textDocument, nextLineOffset);
262+
const nextIndent = DefaultCompletionService.getIndentation(nextLineContent);
263+
// must not be an array item AND not end with `:`
264+
const isValueNode = DefaultCompletionService.isValueNode(textDocument, offset);
265+
if (
266+
!isValueNode &&
267+
lineNonEmptyContent &&
268+
lineNonEmptyContent.length > 0 &&
269+
!lineNonEmptyContent.startsWith('-') &&
270+
!lineNonEmptyContent.endsWith(':') &&
271+
(prevIndent < lineIndent || nextIndent < prevIndent)
272+
) {
273+
processedText = `${textDocument.getText().slice(0, offset)}:${textDocument
274+
.getText()
275+
.slice(offset)}`;
276+
}
277+
}
278+
279+
const result = await this.settings?.documentCache?.get(textDocument, processedText);
232280
if (!result) return CompletionList.create();
233281
const { api } = result;
234282
// if we cannot parse nothing to do
@@ -240,7 +288,6 @@ export class DefaultCompletionService implements CompletionService {
240288
isIncomplete: false,
241289
};
242290

243-
const offset = textDocument.offsetAt(position);
244291
let targetOffset = offset;
245292
let emptyLine = false;
246293

@@ -415,8 +462,22 @@ export class DefaultCompletionService implements CompletionService {
415462
}
416463
}
417464
}
465+
// check if we are at the end of text, get root node if that's the case
466+
const endOfText =
467+
!isJson &&
468+
textDocument.getText().length > 0 &&
469+
(targetOffset >= textDocument.getText().length ||
470+
textDocument.getText().substring(offset, textDocument.getText().length).trim().length ===
471+
0) &&
472+
'\r\n'.indexOf(textDocument.getText().charAt(targetOffset - 1)) !== -1;
473+
474+
if (endOfText) {
475+
targetOffset = 0;
476+
}
418477
// find the current node
419-
const node = findAtOffset({ offset: targetOffset, includeRightBound: true }, api);
478+
const node = endOfText
479+
? api
480+
: findAtOffset({ offset: targetOffset, includeRightBound: true }, api);
420481
// only if we have a node
421482
if (node) {
422483
const caretContext = this.resolveCaretContext(node, targetOffset);
@@ -469,10 +530,12 @@ export class DefaultCompletionService implements CompletionService {
469530
},
470531
};
471532

533+
const word = DefaultCompletionService.getCurrentWord(textDocument, offset);
472534
if (
473535
(isObject(completionNode) || (isArray(completionNode) && isJson)) &&
474536
(CompletionNodeContext.OBJECT === completionNodeContext ||
475-
CompletionNodeContext.VALUE_OBJECT === completionNodeContext) &&
537+
CompletionNodeContext.VALUE_OBJECT === completionNodeContext ||
538+
processedText) &&
476539
(caretContext === CaretContext.KEY_INNER ||
477540
caretContext === CaretContext.KEY_START ||
478541
caretContext === CaretContext.KEY_END ||
@@ -532,6 +595,14 @@ export class DefaultCompletionService implements CompletionService {
532595
a filterText with the content of the target range
533596
*/
534597
// item.filterText = text.substring(location.offset, location.offset + location.length);
598+
if (
599+
word &&
600+
word.length > 0 &&
601+
item.insertText?.replace(/^['"]{1}/g, '').startsWith(word)
602+
) {
603+
item.preselect = true;
604+
}
605+
535606
if (overwriteRange) {
536607
item.filterText = text.substring(
537608
textDocument.offsetAt(overwriteRange.start),
@@ -590,7 +661,6 @@ export class DefaultCompletionService implements CompletionService {
590661
nodeValueFromText.charAt(0) === '"' || nodeValueFromText.charAt(0) === "'"
591662
? nodeValueFromText.charAt(0)
592663
: undefined;
593-
const word = DefaultCompletionService.getCurrentWord(textDocument, offset);
594664
proposed[completionNode.toValue()] = CompletionItem.create('__');
595665
proposed[nodeValueFromText] = CompletionItem.create('__');
596666
// if node is not empty we must replace text
@@ -670,21 +740,21 @@ export class DefaultCompletionService implements CompletionService {
670740
return completionList;
671741
}
672742

673-
private static getCurrentWord(document: TextDocument, offset: number) {
743+
private static getCurrentWord(document: TextDocument | string, offset: number) {
674744
let i = offset - 1;
675-
const text = document.getText();
745+
const text = typeof document === 'string' ? document : document.getText();
676746
while (i >= 0 && ' \t\n\r\v"\':{[,]}'.indexOf(text.charAt(i)) === -1) {
677747
i -= 1;
678748
}
679749
return text.substring(i + 1, offset);
680750
}
681751

682752
private static getRightAfterColonOffset(
683-
document: TextDocument,
753+
document: TextDocument | string,
684754
offset: number,
685755
mustBeEmpty: boolean,
686756
): number {
687-
const text = document.getText();
757+
const text = typeof document === 'string' ? document : document.getText();
688758
let i = offset - 1;
689759
while (i >= 0 && ':'.indexOf(text.charAt(i)) === -1) {
690760
i -= 1;
@@ -706,12 +776,24 @@ export class DefaultCompletionService implements CompletionService {
706776
return rightAfterColon;
707777
}
708778

779+
private static isValueNode(document: TextDocument | string, offset: number): boolean {
780+
const text = typeof document === 'string' ? document : document.getText();
781+
let i = offset - 1;
782+
while (i >= 0 && ':\r\n'.indexOf(text.charAt(i)) === -1) {
783+
i -= 1;
784+
}
785+
if ('\r\n'.indexOf(text.charAt(i)) === -1) {
786+
return true;
787+
}
788+
return false;
789+
}
790+
709791
private static getRightAfterDashOffset(
710-
document: TextDocument,
792+
document: TextDocument | string,
711793
offset: number,
712794
mustBeEmpty: boolean,
713795
): number {
714-
const text = document.getText();
796+
const text = typeof document === 'string' ? document : document.getText();
715797
let i = offset - 1;
716798
while (i >= 0 && '-'.indexOf(text.charAt(i)) === -1) {
717799
i -= 1;
@@ -733,8 +815,8 @@ export class DefaultCompletionService implements CompletionService {
733815
return rightAfterDash;
734816
}
735817

736-
private static getLine(document: TextDocument, offset: number): string {
737-
const text = document.getText();
818+
private static getLine(document: TextDocument | string, offset: number): string {
819+
const text = typeof document === 'string' ? document : document.getText();
738820
let i = offset - 1;
739821
while (i >= 0 && '\r\n'.indexOf(text.charAt(i)) === -1) {
740822
i -= 1;
@@ -748,8 +830,8 @@ export class DefaultCompletionService implements CompletionService {
748830
return text.substring(start + 1, end);
749831
}
750832

751-
private static getLineAfterOffset(document: TextDocument, offset: number): string {
752-
const text = document.getText();
833+
private static getLineAfterOffset(document: TextDocument | string, offset: number): string {
834+
const text = typeof document === 'string' ? document : document.getText();
753835
let i = offset;
754836
while (text.charAt(i).length > 0 && '\n\r'.indexOf(text.charAt(i)) === -1) {
755837
i += 1;
@@ -759,13 +841,17 @@ export class DefaultCompletionService implements CompletionService {
759841
}
760842

761843
private static getNonEmptyContentRange(
762-
document: TextDocument,
844+
document: TextDocument | string,
763845
offset: number,
764846
): Range | undefined {
765847
if (offset < 0) {
766848
return undefined;
767849
}
768-
const text = document.getText();
850+
const text = typeof document === 'string' ? document : document.getText();
851+
const doc =
852+
typeof document === 'string'
853+
? TextDocument.create('foo://bar/spec.yaml', 'json', 0, document)
854+
: document;
769855
let i = offset - 1;
770856
// go to beginning of line
771857
while (i >= 0 && '\r\n'.indexOf(text.charAt(i)) === -1) {
@@ -782,20 +868,19 @@ export class DefaultCompletionService implements CompletionService {
782868
i += 1;
783869
}
784870
// go back to the first non space
785-
// go to the first non space
786871
while (i > start && ' \t\n\r\v'.indexOf(text.charAt(i)) !== -1) {
787872
i -= 1;
788873
}
789874
const end = i + 1;
790875
if (end - start < 1) {
791876
return undefined;
792877
}
793-
const result = Range.create(document.positionAt(start), document.positionAt(end));
878+
const result = Range.create(doc.positionAt(start), doc.positionAt(end));
794879
return result;
795880
}
796881

797-
private static getPreviousLineOffset(document: TextDocument, offset: number): number {
798-
const text = document.getText();
882+
private static getPreviousLineOffset(document: TextDocument | string, offset: number): number {
883+
const text = typeof document === 'string' ? document : document.getText();
799884
let i = offset - 1;
800885
while (i >= 0 && '\r\n'.indexOf(text.charAt(i)) === -1) {
801886
i -= 1;
@@ -806,8 +891,8 @@ export class DefaultCompletionService implements CompletionService {
806891
return i;
807892
}
808893

809-
private static getNextLineOffset(document: TextDocument, offset: number): number {
810-
const text = document.getText();
894+
private static getNextLineOffset(document: TextDocument | string, offset: number): number {
895+
const text = typeof document === 'string' ? document : document.getText();
811896
let i = offset;
812897
while (i < text.length && '\r\n'.indexOf(text.charAt(i)) === -1) {
813898
i += 1;
@@ -822,26 +907,28 @@ export class DefaultCompletionService implements CompletionService {
822907
return i + 1;
823908
}
824909

825-
private static isLastField(document: TextDocument, offset: number): boolean {
826-
const text = document.getText();
910+
private static isLastField(document: TextDocument | string, offset: number): boolean {
911+
const text = typeof document === 'string' ? document : document.getText();
912+
const doc =
913+
typeof document === 'string'
914+
? TextDocument.create('foo://bar/spec.yaml', 'json', 0, document)
915+
: document;
827916
let i = offset;
828917
while (i < text.length && '}]'.indexOf(text.charAt(i)) === -1) {
829918
i += 1;
830919
}
831-
const after = document.getText(
832-
Range.create(document.positionAt(offset), document.positionAt(offset + i)),
833-
);
920+
const after = doc.getText(Range.create(doc.positionAt(offset), doc.positionAt(offset + i)));
834921
if (after.trim().length === 0) {
835922
return true;
836923
}
837924
return false;
838925
}
839926

840-
private static isEmptyLine(document: TextDocument, offset: number): boolean {
927+
private static isEmptyLine(document: TextDocument | string, offset: number): boolean {
841928
return DefaultCompletionService.getLine(document, offset).trim().length === 0;
842929
}
843930

844-
private static isEmptyOrCommaValue(document: TextDocument, offset: number): boolean {
931+
private static isEmptyOrCommaValue(document: TextDocument | string, offset: number): boolean {
845932
const line = DefaultCompletionService.getLineAfterOffset(document, offset).trim();
846933
if (line.length === 0) {
847934
return true;

packages/apidom-ls/src/services/validation/validation-service.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ export class DefaultValidationService implements ValidationService {
100100

101101
const docNs: string = isAsyncDoc(text) ? 'asyncapi' : 'openapi';
102102
// no API document has been parsed
103-
if (api === undefined) return diagnostics;
104-
const specVersion = getSpecVersion(api);
105103
if (result.annotations) {
106104
for (const annotation of result.annotations) {
107105
if (
@@ -112,7 +110,7 @@ export class DefaultValidationService implements ValidationService {
112110
return diagnostics;
113111
}
114112
const nodeSourceMap = getSourceMap(annotation);
115-
const location = { offset: nodeSourceMap.offset, length: 1 };
113+
const location = { offset: nodeSourceMap.offset, length: nodeSourceMap.length };
116114
const range = Range.create(
117115
textDocument.positionAt(location.offset),
118116
textDocument.positionAt(location.offset + location.length),
@@ -146,6 +144,9 @@ export class DefaultValidationService implements ValidationService {
146144
diagnostics.push(diagnostic);
147145
}
148146
}
147+
if (api === undefined) return diagnostics;
148+
const specVersion = getSpecVersion(api);
149+
149150
const hasSyntaxErrors = !!diagnostics.length;
150151
if (!hasSyntaxErrors) {
151152
// TODO (francesco@tumanischvili@smartbear.com) try using the "repaired" version of the doc (serialize apidom skipping errors and missing)

0 commit comments

Comments
 (0)