/** * @import { Diagnostic } from '@codemirror/lint' * @import { ApplicationState } from '../state.js' * @import { ValidationError } from './client.js' * @import { PluginContext } from '../modules/plugin-context.js' */ import { EditorView, ViewUpdate } from '@codemirror/view'; import { linter, lintGutter, forEachDiagnostic, setDiagnostics } from "@codemirror/lint"; import Plugin from '../modules/plugin-base.js'; import ep from '../extension-points.js'; class TeiValidationPlugin extends Plugin { /** @param {PluginContext} context */ constructor(context) { super(context, { name: 'tei-validation', deps: ['xmleditor', 'client'] }); } // Cached dependencies #logger; #client; #xmlEditor; // Private state #validatedVersion = null; #isDisabled = false; #validationInProgress = false; /** @type {Promise|null} */ #validationPromise = null; /** @type {Diagnostic[]} */ #lastDiagnostics = []; #modeCache; /** @param {ApplicationState} state */ async install(state) { await super.install(state); this.#logger = this.getDependency('logger'); this.#client = this.getDependency('client'); this.#xmlEditor = this.getDependency('xmleditor'); this.#logger.debug(`Installing plugin "${this.name}"`); this.#xmlEditor.addLinter([ linter(this.#lintSource.bind(this), { autoPanel: true, delay: 2000, needsRefresh: () => false }), lintGutter() ]); this.#xmlEditor.on('editorUpdateDelayed', (update) => this.#removeDiagnosticsInChangedRanges(update)); } /** * @param {(keyof ApplicationState)[]} changedKeys * @param {ApplicationState} state */ onStateUpdate(changedKeys, state) { if (!changedKeys.some(k => ['offline', 'editorReadOnly', 'xml'].includes(k))) return; if (state.offline || state.editorReadOnly || !state.xml) { this.configure({ mode: 'off' }); } else { this.configure({ mode: 'auto' }); } } static extensionPoints = [ep.validation.validate, ep.validation.inProgress]; /** * Extension point handler for `ep.validation.validate`. * Called by the plugin system to trigger XML validation. * Delegates to {@link TeiValidationPlugin#validate}. * @returns {Promise} */ [ep.validation.validate](...args) { return this.validate(...args) } /** * Extension point handler for `ep.validation.inProgress`. * Called when a validation cycle begins, passing the in-flight promise so this * plugin can defer conflicting operations until validation completes. * Delegates to {@link TeiValidationPlugin#inProgress}. * @param {Promise} promise */ [ep.validation.inProgress](...args) { return this.inProgress(...args) } // // Public API // /** * Configures the validation mode. * @param {{ mode: string }} param0 */ configure({ mode = 'auto' }) { switch (mode) { case 'auto': this.#isDisabled = false; this.#logger.info('Validation is enabled'); break; case 'off': this.#isDisabled = true; this.#logger.info('Validation is disabled'); break; default: throw new Error('Invalid mode parameter'); } this.#modeCache = mode; } /** * Triggers a validation and returns diagnostics. * @returns {Promise} */ async validate() { if (this.#isValidating()) { this.#logger.debug('Validation is ongoing, waiting for it to finish'); return await this.#anyCurrentValidation(); } this.#clearDiagnostics(); const prevDisabled = this.#isDisabled; this.#isDisabled = false; const diagnostics = await new Promise(resolve => { this.#logger.debug('Waiting for validation to start...'); const check = () => { if (this.#isValidating()) { this.#anyCurrentValidation().then(resolve); } else { setTimeout(check, 100); } }; check(); }); this.#isDisabled = prevDisabled; return diagnostics; } /** * Invoked when a validation starts; disables re-entrant validation. * @param {Promise} promise */ async inProgress(promise) { this.#isDisabled = true; await promise; this.#isDisabled = false; } /** @returns {boolean} */ isDisabled() { return this.#isDisabled; } /** @returns {boolean} */ isValidDocument() { return this.#lastDiagnostics.length === 0; } getApi() { return this; } // // Private methods // /** * Lint source function passed to CodeMirror's linter extension. * @param {EditorView} view * @returns {Promise} */ async #lintSource(view) { const doc = view.state.doc; const xml = doc.toString(); if (xml === '') { this.#logger.debug('Nothing to validate.'); return []; } if (this.#isDisabled) { this.#logger.debug('Ignoring validation request: Validation is disabled'); return this.#lastDiagnostics; } if (this.#validationInProgress) { this.#logger.debug('Ignoring validation request: Validation is ongoing.'); return this.#lastDiagnostics; } this.#validationInProgress = true; this.#validationPromise = new Promise(async (resolve) => { /** @type {ValidationError[]} */ let validationErrors; while (true) { this.#validatedVersion = this.#xmlEditor.getDocumentVersion(); this.#logger.debug(`Requesting validation for document version ${this.#validatedVersion}...`); this.context.invokePluginEndpoint(ep.validation.inProgress, [this.#validationPromise]); try { validationErrors = await this.#client.validateXml(xml); } catch (error) { this.#logger.warn(`Validation request failed: ${error.message}`); return resolve([]); } console.log(`Received validation results for document version ${this.#validatedVersion}: ${validationErrors.length} errors.`); if (this.#validatedVersion !== this.#xmlEditor.getDocumentVersion()) { this.#logger.debug('Document has changed, restarting validation...'); } else { const diagnostics = validationErrors.map(error => { if (error.line === undefined || error.column === undefined) { throw new Error('Invalid response from remote validation:' + JSON.stringify(error)); } const lineNum = Math.max(1, Math.min(error.line, doc.lines)); let from, to; try { const line = doc.line(lineNum); from = line.from; to = line.to; const columnOffset = Math.max(0, Math.min(error.column, line.length)); from = Math.max(0, Math.min(from + columnOffset, doc.length - 1)); to = Math.min(Math.max(from + 1, to), doc.length); if (from >= to || from >= doc.length) { from = Math.max(0, Math.min(line.from, doc.length - 1)); to = Math.min(from + 1, doc.length); } } catch (e) { console.warn(`Invalid line/column in validation error:`, error, e); from = 0; to = Math.min(1, doc.length); } return { from, to, severity: 'error', message: error.message || String(error), column: error.column }; }).filter(Boolean); // @ts-ignore return resolve(diagnostics); } } }); let diagnostics; try { diagnostics = await this.#validationPromise; } catch (error) { if (this.#client.lastHttpStatus && this.#client.lastHttpStatus >= 400) { this.#logger.warn('Disabling validation because of server error ' + this.#client.lastHttpStatus); this.configure({ mode: 'off' }); } return this.#lastDiagnostics; } finally { this.#validationInProgress = false; this.#validationPromise = null; } this.#lastDiagnostics = diagnostics; this.context.invokePluginEndpoint(ep.validation.result, [diagnostics]); return diagnostics; } #clearDiagnostics() { this.#lastDiagnostics = []; this.#xmlEditor.getView().dispatch(setDiagnostics(this.#xmlEditor.getView().state, [])); } #isValidating() { return this.#validationInProgress; } /** @returns {Promise} */ #anyCurrentValidation() { return this.#validationInProgress && this.#validationPromise ? this.#validationPromise : Promise.resolve([]); } /** * @param {ViewUpdate} update */ #removeDiagnosticsInChangedRanges(update) { const viewState = this.#xmlEditor.getView().state; /** @type {Diagnostic[]} */ const diagnostics = []; // @ts-ignore — changedRanges is not in the types but exists at runtime const changedRangeValues = Object.values(update.changedRanges[0]); const minRange = Math.min(...changedRangeValues); const maxRange = Math.max(...changedRangeValues); forEachDiagnostic(viewState, (d) => { if (d.from > maxRange || d.to < minRange) { const docLength = viewState.doc.length; const validFrom = Math.max(0, Math.min(d.from, docLength - 1)); const validTo = Math.min(Math.max(validFrom + 1, d.to), docLength); if (validFrom < validTo && validFrom < docLength) { diagnostics.push({ column: null, from: validFrom, to: validTo, severity: d.severity, message: d.message }); } } else { this.#logger.debug('Removing diagnostic ' + JSON.stringify(d)); } }); this.#lastDiagnostics = diagnostics; try { this.#xmlEditor.getView().dispatch(setDiagnostics(viewState, diagnostics)); } catch (error) { this.#logger.warn('Error setting diagnostics after range change:' + String(error)); this.#xmlEditor.getView().dispatch(setDiagnostics(viewState, [])); this.#lastDiagnostics = []; } } } export default TeiValidationPlugin; /** @deprecated Use TeiValidationPlugin class directly */ export const plugin = TeiValidationPlugin;