const {
keymap,
linter,
lintGutter,
nextDiagnostic,
setDiagnosticsEffect,
showPanel,
Diagnostic
} = require( 'ext.CodeMirror.lib' );
const CodeMirrorPanel = require( './codemirror.panel.js' );
/**
* Provides linting support, including gutter markers and a status panel.
*
* @extends CodeMirrorPanel
*/
class CodeMirrorLint extends CodeMirrorPanel {
/**
* Clicking on a diagnostic message will select the relevant code.
*
* @param {Diagnostic[]} diagnostics
* @param {boolean} readOnly Whether the editor is read-only
* @return {Diagnostic[]}
* @internal
* @ignore
*/
static renderDiagnostics( diagnostics, readOnly ) {
return diagnostics.map( ( diagnostic ) => {
const clickableDiagnostic = Object.assign( {}, diagnostic, {
renderMessage( view ) {
const span = document.createElement( 'span' );
span.className = 'cm-diagnosticText-clickable';
// TemplateStyles diagnostics already have a renderMessage method.
if ( diagnostic.renderMessage ) {
span.replaceChildren( diagnostic.renderMessage.call( this, view ) );
} else {
span.textContent = this.message;
}
span.addEventListener( 'click', () => {
view.dispatch( {
selection: { anchor: this.from, head: this.to }
} );
} );
return span;
}
} );
if ( readOnly ) {
delete clickableDiagnostic.actions;
}
return clickableDiagnostic;
} );
}
constructor( lintSource, codemirrorKeymap, lintApi, gotoLine ) {
super();
this.lintSource = lintSource;
this.lintApi = lintApi;
this.keymap = codemirrorKeymap;
this.gotoLine = gotoLine;
this.diagnostics = [];
}
get extension() {
this.keymap.registerKeyBindingHelp( 'lint', 'next-diagnostic', { key: 'F8' } );
// These extensions are only initialized when this.lintSource or this.lintApi are set,
// or CodeMirror#applyLinter is called.
const extension = [
lintGutter(),
keymap.of( [ { key: 'F8', run: nextDiagnostic } ] ),
showPanel.of( ( view ) => {
this.view = view;
return this.panel;
} )
];
if ( this.lintSource ) {
extension.push( linter(
async ( view ) => this.lintSource.disabled ?
[] :
CodeMirrorLint.renderDiagnostics(
await this.lintSource( view ),
view.state.readOnly
)
) );
}
if ( this.lintApi ) {
extension.push( linter(
async ( view ) => this.lintApi.disabled ?
[] :
CodeMirrorLint.renderDiagnostics(
await this.lintApi( view ),
view.state.readOnly
)
) );
}
return extension;
}
get panel() {
const dom = document.createElement( 'div' );
dom.className = 'cm-mw-panel__status';
const worker = document.createElement( 'div' );
worker.className = 'cm-mw-panel__status-worker';
worker.addEventListener( 'click', () => {
nextDiagnostic( this.view );
this.view.focus();
} );
const [ error, errorText ] = this.getLintMarker( 'error' );
const [ warning, warningText ] = this.getLintMarker( 'warning' );
const [ info, infoText ] = this.getLintMarker( 'info' );
worker.append( error, warning, info );
const message = document.createElement( 'div' );
message.className = 'cm-mw-panel__status-message';
const position = document.createElement( 'div' );
position.className = 'cm-mw-panel__status-line';
this.updatePosition( this.view.state, position );
position.addEventListener( 'click', () => this.gotoLine.openPanel( this.view ) );
dom.append( worker, message, position );
return {
dom,
update: ( update ) => {
const { head } = update.state.selection.main;
for ( const tr of update.transactions ) {
for ( const effect of tr.effects ) {
if ( effect.is( setDiagnosticsEffect ) ) {
this.diagnostics = effect.value;
this.updateDiagnosticsCount( 'error', errorText );
this.updateDiagnosticsCount( 'warning', warningText );
this.updateDiagnosticsCount( 'info', infoText );
this.updateDiagnosticMessage( head, message );
}
}
}
if ( update.docChanged || update.selectionSet ) {
this.updatePosition( update.state, position );
this.updateDiagnosticMessage( head, message );
}
}
};
}
getLintMarker( severity ) {
// CSS class names known to be used here include:
// * cm-mw-panel__status--error
// * cm-mw-panel__status--warning
// * cm-mw-panel__status--info
const marker = document.createElement( 'div' );
marker.className = `cm-mw-panel__status--${ severity }`;
const icon = document.createElement( 'div' );
icon.className = `cm-lint-marker-${ severity }`;
const count = document.createElement( 'div' );
count.textContent = '0';
marker.prepend( icon, count );
return [ marker, count ];
}
updateDiagnosticsCount( severity, count ) {
count.textContent = this.diagnostics.filter( ( d ) => d.severity === severity ).length;
}
updateDiagnosticMessage( head, message ) {
const diagnostic = this.diagnostics && this.diagnostics
.find( ( d ) => d.from <= head && d.to >= head );
if ( diagnostic ) {
if ( diagnostic.renderMessage ) {
const rendered = diagnostic.renderMessage( this.view );
// Make the rendered message unclickable.
if ( rendered instanceof Element &&
rendered.classList.contains( 'cm-diagnosticText-clickable' )
) {
message.replaceChildren( ...rendered.childNodes );
} else {
message.replaceChildren( rendered );
}
} else {
message.textContent = diagnostic.message;
}
if ( diagnostic.actions ) {
message.append( ...diagnostic.actions.map( ( { name, tooltip, apply } ) => {
const a = document.createElement( 'button' );
a.type = 'button';
a.className = 'cm-diagnosticAction';
a.textContent = name;
if ( tooltip ) {
a.title = tooltip;
}
a.addEventListener( 'click', ( e ) => {
e.preventDefault();
apply( this.view, diagnostic.from, diagnostic.to );
} );
return a;
} ) );
}
} else {
message.textContent = '';
}
}
updatePosition( state, position ) {
const { anchor, head } = state.selection.main,
line = state.doc.lineAt( head ),
col = head - line.from;
position.textContent = `${ line.number }:${ col }`;
if ( anchor !== head ) {
const line2 = state.doc.lineAt( anchor ),
col2 = anchor - line2.from;
if ( anchor < head ) {
position.textContent += `|(${ line.number - line2.number }:${ Math.max( col - col2, 0 ) })`;
} else {
position.textContent += `|(${ line2.number - line.number }:${ Math.max( col2 - col, 0 ) })`;
}
}
}
}
module.exports = CodeMirrorLint;