cm-spyglass
Version:
A Codemirror extension that provides syntax highlighting, linting, and autocompletion for Minecraft datapacks using SpyglassMC
325 lines (288 loc) • 11.2 kB
JavaScript
import {StateEffect, StateField} from "@codemirror/state";
import {FileNode, Service, VanillaConfig} from "@spyglassmc/core";
import MappedFileSystem from "./FileSystem/MappedFileSystem.js";
import {Decoration, EditorView} from "@codemirror/view";
import {autocompletion} from "@codemirror/autocomplete";
import EditorSyncState from "./EditorSyncState.js";
import DecorationsCache from "./DecorationsCache.js";
import Hint from "./Components/Hint.js";
import {getColorTokenTheme} from "./Components/colorTokenTheme.js";
import InitState from "./Components/InitState.js";
import {linter} from "@codemirror/lint";
import SpyglassPluginOptions from "./SpyglassPluginOptions.js";
import MemoryFileSystem from "./FileSystem/MemoryFileSystem.js";
import {FSAFileSystem, LocalStorageFileSystem} from "../index.js";
/**
* @implements {import("@codemirror/state").Extension}
*/
export default class SpyglassPlugin {
/** @type {import("@codemirror/state").Extension} */ extension;
/** @type {?import("@spyglassmc/core").Service} */ service = null;
/** @type {import("@spyglassmc/core").RootUriString} */ rootUri = 'file:///root/';
/** @type {import("@spyglassmc/core").RootUriString} */ cacheUri = 'file:///cache/';
/** @type {string} */ fileUri;
/** @type {EditorSyncState} */ syncState = new EditorSyncState();
/** @type {?number} */ editorUpdateTimeout = null;
/** @type {number} */ documentVersion = 0;
/** @type {DecorationsCache} */ decorationsCache = new DecorationsCache();
/** @type {Object[]} */ changedRanges = [];
/** @type {?number} */ lastLintedVersion = null;
/** @type {number} */ initState = InitState.UNINITIALIZED;
/** @type {SpyglassPluginOptions} */ options;
/** @type {import("@codemirror/state").StateEffectType} */ updateDecorationsEffect = StateEffect.define();
/** @type {import("@codemirror/state").Extension} */ editorUpdateListener;
/** @type {import("@codemirror/state").Extension} */ completionHandler;
/** @type {import("@codemirror/state").Extension} */ colorTokenField;
/**
* @param {SpyglassPluginOptionsObject} options
*/
constructor(options) {
this.options = new SpyglassPluginOptions().load(options);
this.fileUri = this.rootUri + options.filePath;
this.createSpyglassService();
this.editorUpdateListener = EditorView.updateListener.of(this.handleEditorUpdate.bind(this));
this.completionHandler = autocompletion({override: [this.handleCompletions.bind(this)]});
this.colorTokenField = StateField.define({
create() {
return Decoration.none;
},
update: (value) => {
return this.decorate(value);
},
provide: (f) => EditorView.decorations.from(f),
});
this.extension = [
this.completionHandler,
this.editorUpdateListener,
linter(this.lint.bind(this), { needsRefresh: this.isLintRefreshRequired.bind(this) }),
this.colorTokenField,
getColorTokenTheme(this.options.highlightStyle),
];
}
/**
* Create the Spyglass service using the provided options
* Bundled dependencies are mounted to the file system and initialized
*/
async createSpyglassService() {
let spyglassOptions = this.options.spyglassOptions;
spyglassOptions.project.cacheRoot = this.cacheUri;
if (!spyglassOptions.project.defaultConfig) {
spyglassOptions.project.defaultConfig = VanillaConfig;
}
let fileSystem = new MappedFileSystem()
.mount(this.cacheUri, this.options.cacheFileSystem ?? await this.makePersistentFileSystem('cache'))
.mount(this.rootUri, this.options.rootFileSystem ?? new MemoryFileSystem());
for (let dependency of this.options.dependencies) {
spyglassOptions.project.defaultConfig.env.dependencies.push(dependency.getDependencyName());
spyglassOptions.project.initializers.push(dependency.getInitializer());
fileSystem.mount(dependency.getMountPoint(), dependency.getFileSystem(), dependency.getBaseUri());
}
spyglassOptions.project.externals.fs = fileSystem;
spyglassOptions.project.projectRoots = [this.rootUri];
// noinspection JSCheckFunctionSignatures
this.service = new Service(spyglassOptions);
}
/**
* @param {string} identifier
* @return {Promise<FSAFileSystem|LocalStorageFileSystem>}
*/
async makePersistentFileSystem(identifier) {
if (await FSAFileSystem.isSupported()) {
return FSAFileSystem.create(identifier);
}
return new LocalStorageFileSystem(identifier);
}
/**
* @param {EditorView} view
* @return {Promise<this>}
*/
async initialize(view) {
if (this.initState !== InitState.UNINITIALIZED) {
return this;
}
await this.createSpyglassService();
this.initState = InitState.INITIALISING;
await this.service.project.ready();
await this.service.project.onDidOpen(
this.fileUri,
this.options.languageId,
this.documentVersion,
view.state.doc.toString()
);
await this.service.project.ensureClientManagedChecked(this.fileUri);
this.service.logger.log('Loaded CodeMirror Spyglass plugin');
this.initState = InitState.INITIALIZED;
this.dispatchDecorationsUpdate(view);
await this.service.project.cacheService.save();
return this;
}
/**
* @param update
* @return {Promise<void>}
*/
async handleEditorUpdate(update) {
if (this.initState !== InitState.INITIALIZED) {
await this.initialize(update.view);
return;
}
if (!update.docChanged) {
return;
}
this.changedRanges.push(...update.changedRanges);
this.decorationsCache.flush();
this.dispatchDecorationsUpdate(update.view);
clearTimeout(this.editorUpdateTimeout);
this.syncState.startSync();
this.editorUpdateTimeout = setTimeout(async () => {
let content = update.state.doc.toString();
try {
await this.service.project.onDidChange(this.fileUri, [{text: content}], ++this.documentVersion);
} catch (e) {
this.service.logger.error(e);
}
this.changedRanges = [];
this.dispatchDecorationsUpdate(update.view);
this.syncState.endSync();
}, 20);
}
/**
* Check if linter should be run again
*
* @return {boolean}
*/
isLintRefreshRequired() {
let docAndNode = this.service?.project.getClientManaged(this.fileUri);
if (!docAndNode) {
return false;
}
return this.lastLintedVersion !== docAndNode.doc.version;
}
/**
* @param {EditorView} view
* @return {this}
*/
dispatchDecorationsUpdate(view) {
let transaction = view.state.update({
effects: this.updateDecorationsEffect.of(null)
});
view.dispatch(transaction);
return this;
}
/**
* Apply linting to the current file.
*
* @return {Hint[]}
*/
lint() {
let docAndNode = this.service?.project.getClientManaged(this.fileUri);
if (!docAndNode) {
return [];
}
const {node, doc} = docAndNode;
this.lastLintedVersion = doc.version;
let hints = [];
for (let error of FileNode.getErrors(node)) {
let start = error.range.start;
let end = error.range.end;
[start, end] = this.mapRangeToChanges(start, end);
if (end < start) {
continue;
}
hints.push(new Hint(
start,
end,
error.message,
error.severity === 3 ? 'error' : 'warning'
));
}
return hints;
}
/**
* @param {import("@codemirror/view").DecorationSet} previous
* @return {import("@codemirror/view").DecorationSet}
*/
decorate(previous = Decoration.none) {
let docAndNode = this.service?.project.getClientManaged(this.fileUri)
if (!docAndNode) {
return previous;
}
let {node, doc} = docAndNode;
if (this.decorationsCache.has(doc.version)) {
return this.decorationsCache.get();
}
let tokens = this.service.colorize(node, doc)
let decorations = Decoration.none;
for (let token of tokens) {
let [start, end] = this.mapRangeToChanges(token.range.start, token.range.end);
if (end <= start) {
continue;
}
decorations = decorations.update({
add: [this.getColorTokenMark(token).range(start, end)],
});
}
this.decorationsCache.set(doc.version, decorations);
return decorations;
}
/**
* Create a Codemirror decoration for a Spyglass color token
*
* @param t
* @return {Decoration}
*/
getColorTokenMark(t) {
return Decoration.mark({
class: `spyglassmc-color-token-${t.type} ${
t.modifiers?.map((m) => `spyglassmc-color-token-modifier-${m}`).join() ?? ''
}`,
});
}
/**
* Map highlight/hint ranges from spyglass to the actual locations in the editor,
* based on what has changed since the last update.
*
* @param {number} start
* @param {number} end
* @return {[number, number]}
*/
mapRangeToChanges(start, end) {
for (let change of this.changedRanges) {
if (start >= change.toA) {
start += change.toB - change.toA;
end += change.toB - change.toA;
continue;
}
if (start >= change.fromA && end < change.toA) {
start = 0;
end = 0;
continue;
}
if (end >= change.fromA && end <= change.toA) {
end = Math.min(end, change.toB);
}
}
return [start, end];
}
/**
* Show completions for the current cursor position
*
* @param {import("@codemirror/autocomplete").CompletionContext} ctx
* @return {Promise<?import("@codemirror/autocomplete").CompletionResult>}
*/
async handleCompletions(ctx) {
await this.syncState.wait();
let docAndNodes = await this.service?.project.ensureClientManagedChecked(this.fileUri)
if (!docAndNodes) {
return null
}
let items = this.service.complete(docAndNodes.node, docAndNodes.doc, ctx.pos)
if (!items.length) {
return null
}
return {
from: items[0].range.start,
to: items[0].range.end,
options: items.map((v) => ({label: v.label, detail: v.detail, info: v.documentation})),
}
}
}