308 lines
10 KiB
JavaScript
308 lines
10 KiB
JavaScript
/**
|
|
* A Yaml Editor Extension options for Source Editor
|
|
* @typedef {Object} YamlEditorExtensionOptions
|
|
* @property { boolean } enableComments Convert model nodes with the comment
|
|
* pattern to comments?
|
|
* @property { string } highlightPath Add a line highlight to the
|
|
* node specified by this e.g. `"foo.bar[0]"`
|
|
* @property { * } model Any JS Object that will be stringified and used as the
|
|
* editor's value. Equivalent to using `setDataModel()`
|
|
* @property options SourceEditorExtension Options
|
|
*/
|
|
|
|
import { toPath } from 'lodash';
|
|
import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml';
|
|
import { findPair } from 'yaml/util';
|
|
|
|
export class YamlEditorExtension {
|
|
static get extensionName() {
|
|
return 'YamlEditor';
|
|
}
|
|
|
|
/**
|
|
* Extends the source editor with capabilities for yaml files.
|
|
*
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param {YamlEditorExtensionOptions} setupOptions
|
|
*/
|
|
onSetup(instance, setupOptions = {}) {
|
|
const { enableComments = false, highlightPath = null, model = null } = setupOptions;
|
|
this.enableComments = enableComments;
|
|
this.highlightPath = highlightPath;
|
|
this.model = model;
|
|
|
|
if (model) {
|
|
this.initFromModel(instance, model);
|
|
}
|
|
|
|
instance.onDidChangeModelContent(() => instance.onUpdate());
|
|
}
|
|
|
|
initFromModel(instance, model) {
|
|
const doc = new Document(model);
|
|
if (this.enableComments) {
|
|
YamlEditorExtension.transformComments(doc);
|
|
}
|
|
instance.setValue(doc.toString());
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* This wraps long comments to a maximum line length of 80 chars.
|
|
*
|
|
* The `yaml` package does not currently wrap comments. This function
|
|
* is a local workaround and should be deprecated if
|
|
* https://github.com/eemeli/yaml/issues/322
|
|
* is resolved.
|
|
*/
|
|
static wrapCommentString(string, level = 0) {
|
|
if (!string) {
|
|
return null;
|
|
}
|
|
if (level < 0 || Number.isNaN(parseInt(level, 10))) {
|
|
throw Error(`Invalid value "${level}" for variable \`level\``);
|
|
}
|
|
const maxLineWidth = 80;
|
|
const indentWidth = 2;
|
|
const commentMarkerWidth = '# '.length;
|
|
const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth;
|
|
const lines = [[]];
|
|
string.split(' ').forEach((word) => {
|
|
const currentLine = lines.length - 1;
|
|
if ([...lines[currentLine], word].join(' ').length <= maxLength) {
|
|
lines[currentLine].push(word);
|
|
} else {
|
|
lines.push([word]);
|
|
}
|
|
});
|
|
return lines.map((line) => ` ${line.join(' ')}`).join('\n');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*
|
|
* This utilizes `yaml`'s `visit` function to transform nodes with a
|
|
* comment key pattern to actual comments.
|
|
*
|
|
* In Objects, a key of '#' will be converted to a comment at the top of a
|
|
* property. Any key following the pattern `#|<some key>` will be placed
|
|
* right before `<some key>`.
|
|
*
|
|
* In Arrays, any string that starts with # (including the space), will
|
|
* be converted to a comment at the position it was in.
|
|
*
|
|
* @param { Document } doc
|
|
* @returns { Document }
|
|
*/
|
|
static transformComments(doc) {
|
|
const getLevel = (path) => {
|
|
const { length } = path.filter((x) => isCollection(x));
|
|
return length ? length - 1 : 0;
|
|
};
|
|
|
|
visit(doc, {
|
|
Pair(_, pair, path) {
|
|
const key = pair.key.value;
|
|
// If the key is = '#', we add the value as a comment to the parent
|
|
// We can then remove the node.
|
|
if (key === '#') {
|
|
Object.assign(path[path.length - 1], {
|
|
commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)),
|
|
});
|
|
return visit.REMOVE;
|
|
}
|
|
// If the key starts with `#|`, we want to add a comment to the
|
|
// corresponding property. We can then remove the node.
|
|
if (key.startsWith('#|')) {
|
|
const targetProperty = key.split('|')[1];
|
|
const target = findPair(path[path.length - 1].items, targetProperty);
|
|
if (target) {
|
|
target.key.commentBefore = YamlEditorExtension.wrapCommentString(
|
|
pair.value.value,
|
|
getLevel(path),
|
|
);
|
|
}
|
|
return visit.REMOVE;
|
|
}
|
|
return undefined; // If the node is not a comment, do nothing with it
|
|
},
|
|
// Sequence is basically an array
|
|
Seq(_, node, path) {
|
|
let comment = null;
|
|
const items = node.items.flatMap((child) => {
|
|
if (comment) {
|
|
Object.assign(child, { commentBefore: comment });
|
|
comment = null;
|
|
}
|
|
if (
|
|
isScalar(child) &&
|
|
child.value &&
|
|
child.value.startsWith &&
|
|
child.value.startsWith('#')
|
|
) {
|
|
const commentValue = child.value.replace(/^#\s?/, '');
|
|
comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path));
|
|
return [];
|
|
}
|
|
return child;
|
|
});
|
|
Object.assign(node, { items });
|
|
// Adding a comment in case the last one is a comment
|
|
if (comment) {
|
|
Object.assign(node, { comment });
|
|
}
|
|
},
|
|
});
|
|
return doc;
|
|
}
|
|
|
|
static getDoc(instance) {
|
|
return parseDocument(instance.getValue());
|
|
}
|
|
|
|
static locate(instance, path) {
|
|
if (!path) throw Error(`No path provided.`);
|
|
const blob = instance.getValue();
|
|
const doc = parseDocument(blob);
|
|
const pathArray = toPath(path);
|
|
|
|
if (!doc.getIn(pathArray)) {
|
|
throw Error(`The node ${path} could not be found inside the document.`);
|
|
}
|
|
|
|
const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1));
|
|
let startChar;
|
|
let endChar;
|
|
if (isMap(parentNode)) {
|
|
const node = parentNode.items.find(
|
|
(item) => item.key.value === pathArray[pathArray.length - 1],
|
|
);
|
|
[startChar] = node.key.range;
|
|
[, , endChar] = node.value.range;
|
|
} else {
|
|
const node = doc.getIn(pathArray);
|
|
[startChar, , endChar] = node.range;
|
|
}
|
|
const startSlice = blob.slice(0, startChar);
|
|
const endSlice = blob.slice(0, endChar);
|
|
const startLine = (startSlice.match(/\n/g) || []).length + 1;
|
|
const endLine = (endSlice.match(/\n/g) || []).length;
|
|
return [startLine, endLine];
|
|
}
|
|
|
|
setDoc(instance, doc) {
|
|
if (this.enableComments) {
|
|
YamlEditorExtension.transformComments(doc);
|
|
}
|
|
|
|
if (!instance.getValue()) {
|
|
instance.setValue(doc.toString());
|
|
} else {
|
|
instance.updateValue(doc.toString());
|
|
}
|
|
}
|
|
|
|
highlight(instance, path) {
|
|
// IMPORTANT
|
|
// removeHighlight and highlightLines both come from
|
|
// SourceEditorExtension. So it has to be installed prior to this extension
|
|
if (this.highlightPath === path) return;
|
|
if (!path) {
|
|
instance.removeHighlights();
|
|
} else {
|
|
const res = YamlEditorExtension.locate(instance, path);
|
|
instance.highlightLines(res);
|
|
}
|
|
this.highlightPath = path || null;
|
|
}
|
|
|
|
provides() {
|
|
return {
|
|
/**
|
|
* Get the editor's value parsed as a `Document` as defined by the `yaml`
|
|
* package
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @returns {Document}
|
|
*/
|
|
getDoc: (instance) => YamlEditorExtension.getDoc(instance),
|
|
|
|
/**
|
|
* Accepts a `Document` as defined by the `yaml` package and
|
|
* sets the Editor's value to a stringified version of it.
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param { Document } doc
|
|
*/
|
|
setDoc: (instance, doc) => this.setDoc(instance, doc),
|
|
|
|
/**
|
|
* Returns the parsed value of the Editor's content as JS.
|
|
* @returns {*}
|
|
*/
|
|
getDataModel: (instance) => YamlEditorExtension.getDoc(instance).toJS(),
|
|
|
|
/**
|
|
* Accepts any JS Object and sets the Editor's value to a stringified version
|
|
* of that value.
|
|
*
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param value
|
|
*/
|
|
setDataModel: (instance, value) => this.setDoc(instance, new Document(value)),
|
|
|
|
/**
|
|
* Method to be executed when the Editor's <TextModel> was updated
|
|
*/
|
|
onUpdate: (instance) => {
|
|
if (this.highlightPath) {
|
|
this.highlight(instance, this.highlightPath);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set the editors content to the input without recreating the content model.
|
|
*
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param blob
|
|
*/
|
|
updateValue: (instance, blob) => {
|
|
// Using applyEdits() instead of setValue() ensures that tokens such as
|
|
// highlighted lines aren't deleted/recreated which causes a flicker.
|
|
const model = instance.getModel();
|
|
model.applyEdits([
|
|
{
|
|
// A nice improvement would be to replace getFullModelRange() with
|
|
// a range of the actual diff, avoiding re-formatting the document,
|
|
// but that's something for a later iteration.
|
|
range: model.getFullModelRange(),
|
|
text: blob,
|
|
},
|
|
]);
|
|
},
|
|
|
|
/**
|
|
* Add a line highlight style to the node specified by the path.
|
|
*
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param {string|null|false} path A path to a node of the Editor's value,
|
|
* e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
|
|
* highlights.
|
|
*/
|
|
highlight: (instance, path) => this.highlight(instance, path),
|
|
|
|
/**
|
|
* Return the line numbers of a certain node identified by `path` within
|
|
* the yaml.
|
|
*
|
|
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
|
|
* @param {string} path A path to a node, eg. `foo.bar[0]`
|
|
* @returns {number[]} Array following the schema `[firstLine, lastLine]`
|
|
* (both inclusive)
|
|
*
|
|
* @throws {Error} Will throw if the path is not found inside the document
|
|
*/
|
|
locate: (instance, path) => YamlEditorExtension.locate(instance, path),
|
|
|
|
initFromModel: (instance, model) => this.initFromModel(instance, model),
|
|
};
|
|
}
|
|
}
|