Skip to content

Commit 7cc6e0c

Browse files
evidolobapupier
andauthored
Completion schema URI in modeline comment (#573)
* Provide completion for schema ids in modeline #560 potential improvements: - support range when there is already a value provided - provide completion for local yaml schema files Extracted a specific file for modeline related things. fixes #560 Signed-off-by: Aurélien Pupier <apupier@redhat.com> * Add completion for schema URI im modeline comment Signed-off-by: Yevhen Vydolob <yvydolob@redhat.com> Co-authored-by: Aurélien Pupier <apupier@redhat.com>
1 parent f7377aa commit 7cc6e0c

File tree

13 files changed

+288
-48
lines changed

13 files changed

+288
-48
lines changed

src/languageserver/handlers/settingsHandlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export class SettingsHandler {
195195
uri: schema.url,
196196
fileMatch: [currFileMatch],
197197
priority: SchemaPriority.SchemaStore,
198+
name: schema.name,
199+
description: schema.description,
198200
});
199201
}
200202
}

src/languageservice/parser/yaml-documents.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isArrayEqual } from '../utils/arrUtils';
1616
import { getParent } from '../utils/astUtils';
1717
import { TextBuffer } from '../utils/textBuffer';
1818
import { getIndentation } from '../utils/strings';
19+
import { Token } from 'yaml/dist/parse/cst';
1920

2021
/**
2122
* These documents are collected into a final YAMLDocument
@@ -177,12 +178,15 @@ export class SingleYAMLDocument extends JSONDocument {
177178
* to the `parseYAML` caller.
178179
*/
179180
export class YAMLDocument {
180-
public documents: SingleYAMLDocument[];
181+
documents: SingleYAMLDocument[];
182+
tokens: Token[];
183+
181184
private errors: YAMLDocDiagnostic[];
182185
private warnings: YAMLDocDiagnostic[];
183186

184-
constructor(documents: SingleYAMLDocument[]) {
187+
constructor(documents: SingleYAMLDocument[], tokens: Token[]) {
185188
this.documents = documents;
189+
this.tokens = tokens;
186190
this.errors = [];
187191
this.warnings = [];
188192
}
@@ -219,7 +223,7 @@ export class YamlDocuments {
219223
private ensureCache(document: TextDocument, parserOptions: ParserOptions, addRootObject: boolean): void {
220224
const key = document.uri;
221225
if (!this.cache.has(key)) {
222-
this.cache.set(key, { version: -1, document: new YAMLDocument([]), parserOptions: defaultOptions });
226+
this.cache.set(key, { version: -1, document: new YAMLDocument([], []), parserOptions: defaultOptions });
223227
}
224228
const cacheEntry = this.cache.get(key);
225229
if (

src/languageservice/parser/yamlParser07.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ export function parse(text: string, parserOptions: ParserOptions = defaultOption
3535
const lineCounter = new LineCounter();
3636
const parser = new Parser(lineCounter.addNewLine);
3737
const tokens = parser.parse(text);
38-
const docs = composer.compose(tokens, true);
39-
38+
const tokensArr = Array.from(tokens);
39+
const docs = composer.compose(tokensArr, true, text.length);
4040
// Generate the SingleYAMLDocs from the AST nodes
4141
const yamlDocs: SingleYAMLDocument[] = Array.from(docs, (doc) => parsedDocToSingleYAMLDocument(doc, lineCounter));
4242

4343
// Consolidate the SingleYAMLDocs
44-
return new YAMLDocument(yamlDocs);
44+
return new YAMLDocument(yamlDocs, tokensArr);
4545
}
4646

4747
function parsedDocToSingleYAMLDocument(parsedDoc: Document, lineCounter: LineCounter): SingleYAMLDocument {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Red Hat, Inc. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
'use strict';
6+
import { SingleYAMLDocument } from '../parser/yamlParser07';
7+
import { JSONDocument } from '../parser/jsonParser07';
8+
9+
/**
10+
* Retrieve schema if declared as modeline.
11+
* Public for testing purpose, not part of the API.
12+
* @param doc
13+
*/
14+
export function getSchemaFromModeline(doc: SingleYAMLDocument | JSONDocument): string {
15+
if (doc instanceof SingleYAMLDocument) {
16+
const yamlLanguageServerModeline = doc.lineComments.find((lineComment) => {
17+
return isModeline(lineComment);
18+
});
19+
if (yamlLanguageServerModeline != undefined) {
20+
const schemaMatchs = yamlLanguageServerModeline.match(/\$schema=\S+/g);
21+
if (schemaMatchs !== null && schemaMatchs.length >= 1) {
22+
if (schemaMatchs.length >= 2) {
23+
console.log(
24+
'Several $schema attributes have been found on the yaml-language-server modeline. The first one will be picked.'
25+
);
26+
}
27+
return schemaMatchs[0].substring('$schema='.length);
28+
}
29+
}
30+
}
31+
return undefined;
32+
}
33+
34+
export function isModeline(lineText: string): boolean {
35+
const matchModeline = lineText.match(/^#\s+yaml-language-server\s*:/g);
36+
return matchModeline !== null && matchModeline.length === 1;
37+
}

src/languageservice/services/yamlCompletion.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
CompletionItemKind,
1111
CompletionList,
1212
InsertTextFormat,
13+
InsertTextMode,
1314
MarkupContent,
1415
MarkupKind,
1516
Position,
@@ -31,8 +32,9 @@ import { stringifyObject, StringifySettings } from '../utils/json';
3132
import { isDefined, isString } from '../utils/objects';
3233
import * as nls from 'vscode-nls';
3334
import { setKubernetesParserOption } from '../parser/isKubernetes';
34-
import { isMapContainsEmptyPair } from '../utils/astUtils';
35+
import { isInComment, isMapContainsEmptyPair } from '../utils/astUtils';
3536
import { indexOf } from '../utils/astUtils';
37+
import { isModeline } from './modelineUtil';
3638

3739
const localize = nls.loadMessageBundle();
3840

@@ -172,10 +174,16 @@ export class YamlCompletion {
172174
this.getCustomTagValueCompletions(collector);
173175
}
174176

177+
let lineContent = textBuffer.getLineContent(position.line);
178+
if (lineContent.endsWith('\n')) {
179+
lineContent = lineContent.substr(0, lineContent.length - 1);
180+
}
181+
175182
try {
176183
const schema = await this.schemaService.getSchemaForResource(document.uri, currentDoc);
184+
177185
if (!schema || schema.errors.length) {
178-
if (position.line === 0 && position.character === 0 && !textBuffer.getLineContent(0).includes('# yaml-language-server')) {
186+
if (position.line === 0 && position.character === 0 && !isModeline(lineContent)) {
179187
const inlineSchemaCompletion = {
180188
kind: CompletionItemKind.Text,
181189
label: 'Inline schema',
@@ -184,6 +192,27 @@ export class YamlCompletion {
184192
};
185193
result.items.push(inlineSchemaCompletion);
186194
}
195+
}
196+
197+
if (isModeline(lineContent) || isInComment(doc.tokens, offset)) {
198+
const schemaIndex = lineContent.indexOf('$schema=');
199+
if (schemaIndex !== -1 && schemaIndex + '$schema='.length <= position.character) {
200+
this.schemaService.getAllSchemas().forEach((schema) => {
201+
const schemaIdCompletion: CompletionItem = {
202+
kind: CompletionItemKind.Constant,
203+
label: schema.name ?? schema.uri,
204+
detail: schema.description,
205+
insertText: schema.uri,
206+
insertTextFormat: InsertTextFormat.PlainText,
207+
insertTextMode: InsertTextMode.asIs,
208+
};
209+
result.items.push(schemaIdCompletion);
210+
});
211+
}
212+
return result;
213+
}
214+
215+
if (!schema || schema.errors.length) {
187216
return result;
188217
}
189218

@@ -203,10 +232,6 @@ export class YamlCompletion {
203232
}
204233
}
205234

206-
let lineContent = textBuffer.getLineContent(position.line);
207-
if (lineContent.endsWith('\n')) {
208-
lineContent = lineContent.substr(0, lineContent.length - 1);
209-
}
210235
if (node) {
211236
if (lineContent.length === 0) {
212237
node = currentDoc.internalDocument.contents as Node;

src/languageservice/services/yamlSchemaService.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { SingleYAMLDocument } from '../parser/yamlParser07';
2424
import { JSONDocument } from '../parser/jsonParser07';
2525
import { parse } from 'yaml';
2626
import * as path from 'path';
27+
import { getSchemaFromModeline } from './modelineUtil';
28+
import { JSONSchemaDescriptionExt } from '../../requestTypes';
2729

2830
const localize = nls.loadMessageBundle();
2931

@@ -94,6 +96,8 @@ export class YAMLSchemaService extends JSONSchemaService {
9496
private requestService: SchemaRequestService;
9597
public schemaPriorityMapping: Map<string, Set<SchemaPriority>>;
9698

99+
private schemaUriToNameAndDescription = new Map<string, [string, string]>();
100+
97101
constructor(
98102
requestService: SchemaRequestService,
99103
contextService?: WorkspaceContextService,
@@ -109,6 +113,33 @@ export class YAMLSchemaService extends JSONSchemaService {
109113
this.customSchemaProvider = customSchemaProvider;
110114
}
111115

116+
getAllSchemas(): JSONSchemaDescriptionExt[] {
117+
const result: JSONSchemaDescriptionExt[] = [];
118+
const schemaUris = new Set<string>();
119+
for (const filePattern of this.filePatternAssociations) {
120+
const schemaUri = filePattern.uris[0];
121+
if (schemaUris.has(schemaUri)) {
122+
continue;
123+
}
124+
schemaUris.add(schemaUri);
125+
const schemaHandle: JSONSchemaDescriptionExt = {
126+
uri: schemaUri,
127+
fromStore: false,
128+
usedForCurrentFile: false,
129+
};
130+
131+
if (this.schemaUriToNameAndDescription.has(schemaUri)) {
132+
const [name, description] = this.schemaUriToNameAndDescription.get(schemaUri);
133+
schemaHandle.name = name;
134+
schemaHandle.description = description;
135+
schemaHandle.fromStore = true;
136+
}
137+
result.push(schemaHandle);
138+
}
139+
140+
return result;
141+
}
142+
112143
async resolveSchemaContent(
113144
schemaToResolve: UnresolvedSchema,
114145
schemaURL: string,
@@ -294,7 +325,7 @@ export class YAMLSchemaService extends JSONSchemaService {
294325
const seen: { [schemaId: string]: boolean } = Object.create(null);
295326
const schemas: string[] = [];
296327

297-
let schemaFromModeline = this.getSchemaFromModeline(doc);
328+
let schemaFromModeline = getSchemaFromModeline(doc);
298329
if (schemaFromModeline !== undefined) {
299330
if (!schemaFromModeline.startsWith('file:') && !schemaFromModeline.startsWith('http')) {
300331
if (!path.isAbsolute(schemaFromModeline)) {
@@ -438,32 +469,6 @@ export class YAMLSchemaService extends JSONSchemaService {
438469
return priorityMapping.get(highestPrio) || [];
439470
}
440471

441-
/**
442-
* Retrieve schema if declared as modeline.
443-
* Public for testing purpose, not part of the API.
444-
* @param doc
445-
*/
446-
public getSchemaFromModeline(doc: SingleYAMLDocument | JSONDocument): string {
447-
if (doc instanceof SingleYAMLDocument) {
448-
const yamlLanguageServerModeline = doc.lineComments.find((lineComment) => {
449-
const matchModeline = lineComment.match(/^#\s+yaml-language-server\s*:/g);
450-
return matchModeline !== null && matchModeline.length === 1;
451-
});
452-
if (yamlLanguageServerModeline != undefined) {
453-
const schemaMatchs = yamlLanguageServerModeline.match(/\$schema=\S+/g);
454-
if (schemaMatchs !== null && schemaMatchs.length >= 1) {
455-
if (schemaMatchs.length >= 2) {
456-
console.log(
457-
'Several $schema attributes have been found on the yaml-language-server modeline. The first one will be picked.'
458-
);
459-
}
460-
return schemaMatchs[0].substring('$schema='.length);
461-
}
462-
}
463-
}
464-
return undefined;
465-
}
466-
467472
private async resolveCustomSchema(schemaUri, doc): ResolvedSchema {
468473
const unresolvedSchema = await this.loadSchema(schemaUri);
469474
const schema = await this.resolveSchemaContent(unresolvedSchema, schemaUri, []);
@@ -635,11 +640,25 @@ export class YAMLSchemaService extends JSONSchemaService {
635640
);
636641
}
637642
unresolvedJsonSchema.uri = schemaUri;
643+
if (this.schemaUriToNameAndDescription.has(schemaUri)) {
644+
const [name, description] = this.schemaUriToNameAndDescription.get(schemaUri);
645+
unresolvedJsonSchema.schema.title = name ?? unresolvedJsonSchema.schema.title;
646+
unresolvedJsonSchema.schema.description = description ?? unresolvedJsonSchema.schema.description;
647+
}
638648
return unresolvedJsonSchema;
639649
});
640650
}
641651

642-
registerExternalSchema(uri: string, filePatterns?: string[], unresolvedSchema?: JSONSchema): SchemaHandle {
652+
registerExternalSchema(
653+
uri: string,
654+
filePatterns?: string[],
655+
unresolvedSchema?: JSONSchema,
656+
name?: string,
657+
description?: string
658+
): SchemaHandle {
659+
if (name || description) {
660+
this.schemaUriToNameAndDescription.set(uri, [name, description]);
661+
}
643662
return super.registerExternalSchema(uri, filePatterns, unresolvedSchema);
644663
}
645664

src/languageservice/utils/astUtils.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { Document, isDocument, isScalar, Node, visit as cstVisit, YAMLMap, YAMLSeq } from 'yaml';
6+
import { Document, isDocument, isScalar, Node, visit, YAMLMap, YAMLSeq } from 'yaml';
7+
import { CollectionItem, SourceToken, Token } from 'yaml/dist/parse/cst';
8+
import { VisitPath } from 'yaml/dist/parse/cst-visit';
9+
10+
type Visitor = (item: SourceToken, path: VisitPath) => number | symbol | Visitor | void;
711

812
export function getParent(doc: Document, nodeToFind: Node): Node | undefined {
913
let parentNode: Node;
10-
cstVisit(doc, (_, node: Node, path) => {
14+
visit(doc, (_, node: Node, path) => {
1115
if (node === nodeToFind) {
1216
parentNode = path[path.length - 1] as Node;
13-
return cstVisit.BREAK;
17+
return visit.BREAK;
1418
}
1519
});
1620

@@ -41,3 +45,74 @@ export function indexOf(seq: YAMLSeq, item: Node): number | undefined {
4145
}
4246
return undefined;
4347
}
48+
49+
/**
50+
* Check that given offset is in YAML comment
51+
* @param doc the yaml document
52+
* @param offset the offset to check
53+
*/
54+
export function isInComment(tokens: Token[], offset: number): boolean {
55+
let inComment = false;
56+
for (const token of tokens) {
57+
if (token.type === 'document') {
58+
_visit([], (token as unknown) as SourceToken, (item) => {
59+
if (isCollectionItem(item) && item.value?.type === 'comment') {
60+
if (token.offset <= offset && item.value.source.length + item.value.offset >= offset) {
61+
inComment = true;
62+
return visit.BREAK;
63+
}
64+
} else if (item.type === 'comment' && item.offset <= offset && item.offset + item.source.length >= offset) {
65+
inComment = true;
66+
return visit.BREAK;
67+
}
68+
});
69+
} else if (token.type === 'comment') {
70+
if (token.offset <= offset && token.source.length + token.offset >= offset) {
71+
return true;
72+
}
73+
}
74+
if (inComment) {
75+
break;
76+
}
77+
}
78+
79+
return inComment;
80+
}
81+
82+
function isCollectionItem(token: unknown): token is CollectionItem {
83+
return token['start'] !== undefined;
84+
}
85+
86+
function _visit(path: VisitPath, item: SourceToken, visitor: Visitor): number | symbol | Visitor | void {
87+
let ctrl = visitor(item, path);
88+
if (typeof ctrl === 'symbol') return ctrl;
89+
for (const field of ['key', 'value'] as const) {
90+
const token = item[field];
91+
if (token && 'items' in token) {
92+
for (let i = 0; i < token.items.length; ++i) {
93+
const ci = _visit(Object.freeze(path.concat([[field, i]])), token.items[i], visitor);
94+
if (typeof ci === 'number') i = ci - 1;
95+
else if (ci === visit.BREAK) return visit.BREAK;
96+
else if (ci === visit.REMOVE) {
97+
token.items.splice(i, 1);
98+
i -= 1;
99+
}
100+
}
101+
if (typeof ctrl === 'function' && field === 'key') ctrl = ctrl(item, path);
102+
}
103+
}
104+
105+
const token = item['sep'];
106+
if (token) {
107+
for (let i = 0; i < token.length; ++i) {
108+
const ci = _visit(Object.freeze(path), token[i], visitor);
109+
if (typeof ci === 'number') i = ci - 1;
110+
else if (ci === visit.BREAK) return visit.BREAK;
111+
else if (ci === visit.REMOVE) {
112+
token.items.splice(i, 1);
113+
i -= 1;
114+
}
115+
}
116+
}
117+
return typeof ctrl === 'function' ? ctrl(item, path) : ctrl;
118+
}

0 commit comments

Comments
 (0)