Skip to content

Commit 5e63024

Browse files
committed
Add completion for schema URI im modeline comment
Signed-off-by: Yevhen Vydolob <yvydolob@redhat.com>
1 parent 1dbb029 commit 5e63024

File tree

11 files changed

+223
-24
lines changed

11 files changed

+223
-24
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
@@ -18,6 +18,7 @@ import { ApplicableSchema, SchemaCollectorImpl, validate } from './json-schema07
1818
import { JSONSchema } from '../jsonSchema';
1919
import { TextBuffer } from '../utils/textBuffer';
2020
import { getIndentation } from '../utils/strings';
21+
import { Token } from 'yaml/dist/parse/cst';
2122

2223
/**
2324
* These documents are collected into a final YAMLDocument
@@ -194,12 +195,15 @@ export class SingleYAMLDocument extends JSONDocument {
194195
* to the `parseYAML` caller.
195196
*/
196197
export class YAMLDocument {
197-
public documents: SingleYAMLDocument[];
198+
documents: SingleYAMLDocument[];
199+
tokens: Token[];
200+
198201
private errors: YAMLDocDiagnostic[];
199202
private warnings: YAMLDocDiagnostic[];
200203

201-
constructor(documents: SingleYAMLDocument[]) {
204+
constructor(documents: SingleYAMLDocument[], tokens: Token[]) {
202205
this.documents = documents;
206+
this.tokens = tokens;
203207
this.errors = [];
204208
this.warnings = [];
205209
}
@@ -236,7 +240,7 @@ export class YamlDocuments {
236240
private ensureCache(document: TextDocument, parserOptions: ParserOptions, addRootObject: boolean): void {
237241
const key = document.uri;
238242
if (!this.cache.has(key)) {
239-
this.cache.set(key, { version: -1, document: new YAMLDocument([]), parserOptions: defaultOptions });
243+
this.cache.set(key, { version: -1, document: new YAMLDocument([], []), parserOptions: defaultOptions });
240244
}
241245
const cacheEntry = this.cache.get(key);
242246
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 {

src/languageservice/services/yamlCompletion.ts

Lines changed: 23 additions & 14 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,7 +32,7 @@ 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';
3637
import { isModeline } from './modelineUtil';
3738

@@ -180,6 +181,7 @@ export class YamlCompletion {
180181

181182
try {
182183
const schema = await this.schemaService.getSchemaForResource(document.uri, currentDoc);
184+
183185
if (!schema || schema.errors.length) {
184186
if (position.line === 0 && position.character === 0 && !isModeline(lineContent)) {
185187
const inlineSchemaCompletion = {
@@ -190,23 +192,30 @@ export class YamlCompletion {
190192
};
191193
result.items.push(inlineSchemaCompletion);
192194
}
193-
if (isModeline(lineContent)) {
194-
const schemaIndex = lineContent.indexOf('$schema=');
195-
if (schemaIndex !== -1 && schemaIndex + '$schema='.length === position.character) {
196-
this.schemaService.getRegisteredSchemaIds().forEach((schemaId) => {
197-
const schemaIdCompletion = {
198-
kind: CompletionItemKind.Text,
199-
label: schemaId,
200-
insertText: schemaId,
201-
insertTextFormat: InsertTextFormat.PlainText,
202-
};
203-
result.items.push(schemaIdCompletion);
204-
});
205-
}
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+
});
206211
}
207212
return result;
208213
}
209214

215+
if (!schema || schema.errors.length) {
216+
return result;
217+
}
218+
210219
let currentProperty: Node = null;
211220
let foundByClosest = false;
212221
if (!node) {

src/languageservice/services/yamlSchemaService.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { JSONDocument } from '../parser/jsonParser07';
2525
import { parse } from 'yaml';
2626
import * as path from 'path';
2727
import { getSchemaFromModeline } from './modelineUtil';
28+
import { JSONSchemaDescriptionExt } from '../../requestTypes';
2829

2930
const localize = nls.loadMessageBundle();
3031

@@ -95,6 +96,8 @@ export class YAMLSchemaService extends JSONSchemaService {
9596
private requestService: SchemaRequestService;
9697
public schemaPriorityMapping: Map<string, Set<SchemaPriority>>;
9798

99+
private schemaUriToNameAndDescription = new Map<string, [string, string]>();
100+
98101
constructor(
99102
requestService: SchemaRequestService,
100103
contextService?: WorkspaceContextService,
@@ -110,6 +113,33 @@ export class YAMLSchemaService extends JSONSchemaService {
110113
this.customSchemaProvider = customSchemaProvider;
111114
}
112115

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+
113143
async resolveSchemaContent(
114144
schemaToResolve: UnresolvedSchema,
115145
schemaURL: string,
@@ -610,11 +640,25 @@ export class YAMLSchemaService extends JSONSchemaService {
610640
);
611641
}
612642
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+
}
613648
return unresolvedJsonSchema;
614649
});
615650
}
616651

617-
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+
}
618662
return super.registerExternalSchema(uri, filePatterns, unresolvedSchema);
619663
}
620664

src/languageservice/utils/astUtils.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
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;
@@ -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+
}

src/languageservice/yamlLanguageService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export interface SchemasSettings {
6262
fileMatch: string[];
6363
schema?: unknown;
6464
uri: string;
65+
name?: string;
66+
description?: string;
6567
}
6668

6769
export interface LanguageSettings {
@@ -173,7 +175,13 @@ export function getLanguageService(
173175
settings.schemas.forEach((settings) => {
174176
const currPriority = settings.priority ? settings.priority : 0;
175177
schemaService.addSchemaPriority(settings.uri, currPriority);
176-
schemaService.registerExternalSchema(settings.uri, settings.fileMatch, settings.schema);
178+
schemaService.registerExternalSchema(
179+
settings.uri,
180+
settings.fileMatch,
181+
settings.schema,
182+
settings.name,
183+
settings.description
184+
);
177185
});
178186
}
179187
yamlValidation.configure(settings);

src/requestTypes.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ import { SchemaConfiguration } from './languageservice/yamlLanguageService';
66

77
export type ISchemaAssociations = Record<string, string[]>;
88

9+
export interface JSONSchemaDescription {
10+
/**
11+
* Schema URI
12+
*/
13+
uri: string;
14+
/**
15+
* Schema name, from schema store
16+
*/
17+
name?: string;
18+
/**
19+
* Schema description, from schema store
20+
*/
21+
description?: string;
22+
}
23+
24+
export interface JSONSchemaDescriptionExt extends JSONSchemaDescription {
25+
/**
26+
* Is schema used for current document
27+
*/
28+
usedForCurrentFile: boolean;
29+
/**
30+
* Is schema from schema store
31+
*/
32+
fromStore: boolean;
33+
}
34+
935
export namespace SchemaAssociationNotification {
1036
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1137
export const type: NotificationType<ISchemaAssociations | SchemaConfiguration[]> = new NotificationType(

test/astUtils.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as chai from 'chai';
77
import { isPair, isSeq, Pair, YAMLSeq } from 'yaml';
88
import { YamlDocuments } from '../src/languageservice/parser/yaml-documents';
9-
import { getParent } from '../src/languageservice/utils/astUtils';
9+
import { getParent, isInComment } from '../src/languageservice/utils/astUtils';
1010
import { setupTextDocument } from './utils/testHelper';
1111
const expect = chai.expect;
1212

@@ -74,4 +74,20 @@ describe('AST Utils Tests', () => {
7474
expect((result as Pair).key).property('value', 'foo');
7575
});
7676
});
77+
78+
describe('Is Offset in comment', () => {
79+
it('should detect that offset in comment', () => {
80+
const doc = setupTextDocument('#some comment\nfoo: bar');
81+
const yamlDoc = documents.getYamlDocument(doc);
82+
const result = isInComment(yamlDoc.tokens, 4);
83+
expect(result).to.be.true;
84+
});
85+
86+
it('should detect that comment inside object', () => {
87+
const doc = setupTextDocument('obj:\n#some comment\n foo: bar');
88+
const yamlDoc = documents.getYamlDocument(doc);
89+
const result = isInComment(yamlDoc.tokens, 12);
90+
expect(result).to.be.true;
91+
});
92+
});
7793
});

0 commit comments

Comments
 (0)