Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitlabCI Support #973

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ The following settings are supported:
- `[yaml].editor.formatOnType`: Enable/disable on type indent and auto formatting array
- `yaml.disableDefaultProperties`: Disable adding not required properties with default values into completion text
- `yaml.suggest.parentSkeletonSelectedFirst`: If true, the user must select some parent skeleton first before autocompletion starts to suggest the rest of the properties.\nWhen yaml object is not empty, autocompletion ignores this setting and returns all properties and skeletons.
- `yaml.style.flowMapping` : Forbids flow style mappings if set to `forbid`
- `yaml.style.flowMapping` : Forbids flow style mappings if set to `forbid`
- `yaml.style.flowSequence` : Forbids flow style sequences if set to `forbid`
- `yaml.keyOrdering` : Enforces alphabetical ordering of keys in mappings when set to `true`. Default is `false`
- `yaml.gitlabci.enabled` : Enables gitlab-ci add-ons
- `yaml.gitlabci.codelensEnabled` : Enables gitlab-ci related code lens

##### Adding custom tags

Expand Down
5 changes: 5 additions & 0 deletions src/languageserver/handlers/settingsHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export class SettingsHandler {
flowSequence: settings.yaml.style?.flowSequence ?? 'allow',
};
this.yamlSettings.keyOrdering = settings.yaml.keyOrdering ?? false;
if (settings.yaml.gitlabci) {
this.yamlSettings.gitlabci.enabled = settings.yaml.gitlabci.enabled ?? true;
this.yamlSettings.gitlabci.codelensEnabled = settings.yaml.gitlabci.codelensEnabled ?? true;
}
}

this.yamlSettings.schemaConfigurationSettings = [];
Expand Down Expand Up @@ -259,6 +263,7 @@ export class SettingsHandler {
flowSequence: this.yamlSettings.style?.flowSequence,
yamlVersion: this.yamlSettings.yamlVersion,
keyOrdering: this.yamlSettings.keyOrdering,
gitlabci: this.yamlSettings.gitlabci,
};

if (this.yamlSettings.schemaAssociations) {
Expand Down
11 changes: 11 additions & 0 deletions src/languageservice/parser/yaml-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ interface YamlCachedDocument {
export class YamlDocuments {
// a mapping of URIs to cached documents
private cache = new Map<string, YamlCachedDocument>();
private textDocumentMapping = new Map<string, TextDocument>();

/**
* Get cached YAMLDocument
Expand All @@ -272,9 +273,19 @@ export class YamlDocuments {
*/
getYamlDocument(document: TextDocument, parserOptions?: ParserOptions, addRootObject = false): YAMLDocument {
this.ensureCache(document, parserOptions ?? defaultOptions, addRootObject);
this.textDocumentMapping.set(document.uri, document);
return this.cache.get(document.uri).document;
}

getAllDocuments(): [string, YAMLDocument, TextDocument][] {
const documents: [string, YAMLDocument, TextDocument][] = [];
for (const [uri, doc] of this.cache.entries()) {
const txtdoc = this.textDocumentMapping.get(uri);
documents.push([uri, doc.document, txtdoc]);
}
return documents;
}

/**
* For test purpose only!
*/
Expand Down
223 changes: 223 additions & 0 deletions src/languageservice/services/gitlabciUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Red Hat, Inc. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { TextDocument } from 'vscode-languageserver-textdocument';
import { LocationLink, Position, Range } from 'vscode-languageserver-types';
import { isSeq, isMap, isScalar, isPair, YAMLMap, Node, Pair, isNode, Scalar, visit } from 'yaml';
import { SingleYAMLDocument, YAMLDocument, yamlDocumentsCache } from '../parser/yaml-documents';
import { readFileSync, readdirSync, statSync } from 'fs';
import { WorkspaceFolder } from 'vscode-languageserver';
import { URI } from 'vscode-uri';

// Find node within all yaml documents
export function findNodeFromPath(
allDocuments: [string, YAMLDocument, TextDocument][],
path: string[]
): [string, Pair<unknown, unknown>, TextDocument] | undefined {
for (const [uri, docctx, doctxt] of allDocuments) {
for (const doc of docctx.documents) {
if (isMap(doc.internalDocument.contents)) {
let node: YAMLMap<unknown, unknown> = doc.internalDocument.contents;
// Follow path
for (let i = 0; i < path.length; ++i) {
const target = node.items.find(({ key: key }) => key == path[i]);
if (target && i == path.length - 1) {
return [uri, target, doctxt];
} else if (target && isMap(target.value)) {
node = target.value;
} else {
break;
}
}
}
}
}
}

// Like findNodeFromPath but will follow extends tags
export function findNodeFromPathRecursive(
allDocuments: [string, YAMLDocument, TextDocument][],
path: string[],
maxDepth = 16
): [string, Pair<unknown, unknown>, TextDocument][] {
const result = [];
let pathResult = findNodeFromPath(allDocuments, path);
for (let i = 0; pathResult && i < maxDepth; ++i) {
result.push(pathResult);
const target = pathResult[1];
path = null;
if (isMap(target.value)) {
// Find extends within result
const extendsNode = findChildWithKey(target.value, 'extends');
if (extendsNode) {
// Only follow the first extends tag
if (isScalar(extendsNode.value)) {
path = [extendsNode.value.value as string];
} else if (isSeq(extendsNode.value) && isScalar(extendsNode.value.items[0])) {
path = [extendsNode.value.items[0].value as string];
}
}
}
if (path === null) {
break;
}
pathResult = findNodeFromPath(allDocuments, path);
}

return result;
}

// Will create a LocationLink from a pair node
export function createDefinitionFromTarget(target: Pair<Node, Node>, document: TextDocument, uri: string): LocationLink {
const start = target.key.range[0];
const endDef = target.key.range[1];
const endFull = target.value.range[2];
const targetRange = Range.create(document.positionAt(start), document.positionAt(endFull));
const selectionRange = Range.create(document.positionAt(start), document.positionAt(endDef));

return LocationLink.create(uri, targetRange, selectionRange);
}

// Returns whether or not the node has a parent with the given key
// Useful to find the parent for nested nodes (e.g. extends with an array)
export function findParentWithKey(node: Node, key: string, currentDoc: SingleYAMLDocument, maxDepth = 2): Pair {
let parent = currentDoc.getParent(node);
for (let i = 0; i < maxDepth; ++i) {
if (parent && isPair(parent) && isScalar(parent.key) && parent.key.value === key) {
return parent;
}
parent = currentDoc.getParent(parent);
}

return null;
}

// Find if possible a child with the given key
export function findChildWithKey(node: YAMLMap, targetKey: string): Pair | undefined {
return node.items.find(({ key: key }) => key == targetKey);
}

// Get all potential job nodes from all documents
// A job node is a map node at the root of the document
export function getJobNodes(
allDocuments: [string, YAMLDocument, TextDocument][]
): [LocationLink, TextDocument, Pair<Node, YAMLMap>][] {
const jobNodes = [];
for (const [uri, docctx, doctxt] of allDocuments) {
for (const doc of docctx.documents) {
if (isMap(doc.internalDocument.contents)) {
for (const node of doc.internalDocument.contents.items) {
if (isNode(node.key) && isMap(node.value)) {
const loc = createDefinitionFromTarget(node as Pair<Node, Node>, doctxt, uri);
jobNodes.push([loc, doctxt, node]);
}
}
}
}
}

return jobNodes;
}

// Find where jobs are used, such as within extends or needs nodes and reference tags
export function findUsages(allDocuments: [string, YAMLDocument, TextDocument][]): Map<string, LocationLink[]> {
const targetAttributes = ['extends', 'needs'];
const usages = new Map<string, LocationLink[]>();
const jobNodes = getJobNodes(allDocuments);

for (const [jobLoc, doc, job] of jobNodes) {
// !reference tags
visit(job.value, (_, node) => {
// Support only top level jobs so the sequence must be of length 1
if (isSeq(node) && node.tag === '!reference' && node.items.length === 1 && isScalar(node.items[0])) {
const jobName = node.items[0].value as string;
const range = Range.create(doc.positionAt(node.items[0].range[0]), doc.positionAt(node.items[0].range[1]));
const loc = LocationLink.create(jobLoc.targetUri, range, range);
if (usages.has(jobName)) usages.get(jobName).push(loc);
else usages.set(jobName, [loc]);
}
});

// Extends / needs attributes
// For each attribute of each job
for (const item of job.value.items) {
if (isScalar(item.key)) {
if (targetAttributes.includes(item.key.value as string)) {
const referencedJobs: Scalar[] = [];

// Get all job names
if (isScalar(item.value) && typeof item.value.value === 'string') {
referencedJobs.push(item.value);
} else if (isSeq(item.value)) {
for (const seqItem of item.value.items) {
if (isScalar(seqItem) && typeof seqItem.value === 'string') {
referencedJobs.push(seqItem);
}
}
}

for (const referencedJob of referencedJobs) {
const jobName = referencedJob.value as string;
const targetRange = Range.create(doc.positionAt(referencedJob.range[0]), doc.positionAt(referencedJob.range[1]));
const loc = LocationLink.create(jobLoc.targetUri, targetRange, targetRange);

// Add it to the references
if (usages.has(jobName)) usages.get(jobName).push(loc);
else usages.set(jobName, [loc]);
}
}
}
}
}

return usages;
}

export function toExportedPos(pos: Position): object {
return { lineNumber: pos.line + 1, column: pos.character + 1 };
}

export function toExportedRange(range: Range): object {
return {
startLineNumber: range.start.line + 1,
startColumn: range.start.character + 1,
endLineNumber: range.end.line + 1,
endColumn: range.end.character + 1,
};
}

// Parse the file at this parse and add it to the cache
function registerFile(path: string): void {
const content = readFileSync(path, 'utf8');
const doc = TextDocument.create('file://' + path, 'yaml', 1, content);
yamlDocumentsCache.getYamlDocument(doc);
}

function registerWorkspaceFiles(path: string): void {
try {
const files = readdirSync(path);
for (const file of files) {
const filePath = path + '/' + file;
const stats = statSync(filePath);

if (file.endsWith('.yaml') || file.endsWith('.yml')) {
registerFile(filePath);
} else if (stats.isDirectory()) {
registerWorkspaceFiles(filePath);
}
}
} catch (e) {
console.warn('Error reading directory: ' + path + ', ignoring it');
return;
}
}

// Walk through all the files in the workspace and put them in cache
// Useful to have cross files references for gitlabci
export function registerWorkspaces(workspaceFolders: WorkspaceFolder[]): void {
for (const folder of workspaceFolders) {
registerWorkspaceFiles(URI.parse(folder.uri).fsPath);
}
}
46 changes: 45 additions & 1 deletion src/languageservice/services/yamlCodeLens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,58 @@ import { Telemetry } from '../telemetry';
import { getSchemaUrls } from '../utils/schemaUrls';
import { convertErrorToTelemetryMsg } from '../utils/objects';
import { getSchemaTitle } from '../utils/schemaUtils';
import { isMap, isPair, isScalar } from 'yaml';
import { findUsages, toExportedPos, toExportedRange } from './gitlabciUtils';
import { URI } from 'vscode-uri';
import { SettingsState } from '../../yamlSettings';

export class YamlCodeLens {
constructor(private schemaService: YAMLSchemaService, private readonly telemetry?: Telemetry) {}
constructor(
private schemaService: YAMLSchemaService,
private readonly telemetry?: Telemetry,
private readonly settings?: SettingsState
) {}

async getCodeLens(document: TextDocument): Promise<CodeLens[]> {
const result = [];
try {
const yamlDocument = yamlDocumentsCache.getYamlDocument(document);

if (this.settings?.gitlabci?.enabled && this.settings?.gitlabci?.codelensEnabled) {
// GitlabCI Job Usages
const usages = findUsages(yamlDocumentsCache.getAllDocuments());
for (const doc of yamlDocument.documents) {
if (isMap(doc.internalDocument.contents)) {
for (const jobNode of doc.internalDocument.contents.items) {
// If at least one usage
if (isPair(jobNode) && isScalar(jobNode.key) && usages.has(jobNode.key.value as string)) {
const jobUsages = usages.get(jobNode.key.value as string);
const nodeRange = Range.create(
document.positionAt(jobNode.key.range[0]),
document.positionAt(jobNode.key.range[1])
);
const lens = CodeLens.create(nodeRange);
// Locations for all usages
const locations = [];
for (const loc of jobUsages) {
locations.push({
uri: URI.parse(loc.targetUri),
range: toExportedRange(loc.targetRange),
});
}
lens.command = {
title: jobUsages.length === 1 ? '1 usage' : `${jobUsages.length} usages`,
command: 'editor.action.peekLocations',
arguments: [URI.parse(document.uri), toExportedPos(nodeRange.end), locations],
};

result.push(lens);
}
}
}
}
}

let schemaUrls = new Map<string, JSONSchema>();
for (const currentYAMLDoc of yamlDocument.documents) {
const schema = await this.schemaService.getSchemaForResource(document.uri, currentYAMLDoc);
Expand Down
Loading