const {
CompletionSource,
EditorSelection,
Extension,
Prec,
acceptCompletion,
autocompletion,
insertCompletionText,
pickedCompletion,
startCompletion,
syntaxTree,
keymap
} = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
/**
* Keymap for autocompletion.
*
* @type {CodeMirrorKeyBinding[]}
* @memberof module:CodeMirrorAutocomplete
*/
const autocompleteKeymap = [
{
key: 'Shift-Enter',
aliases: [ 'Ctrl-Space' ],
run: startCompletion,
tool: 'startcompletion'
},
{
key: 'Tab',
aliases: [ 'Enter' ],
run: acceptCompletion,
tool: 'autocomplete'
}
];
/**
* CodeMirror extension providing
* autocompletion
* for the MediaWiki mode. This automatically applied when using {@link CodeMirrorModeMediaWiki}.
*
* @module CodeMirrorAutocomplete
* @type {Extension}
*/
const autocompleteExtension = [
autocompletion( { defaultKeymap: true } ),
Prec.high( keymap.of( autocompleteKeymap ) )
];
/**
* Check if the node has a specific type.
*
* @param {Set<string>} types
* @param {string[]|string} names
* @return {boolean}
*/
const hasTag = ( types, names ) => ( Array.isArray( names ) ? names : [ names ] )
.some( ( name ) => types.has( mwModeConfig.tags[ name ] ) );
const api = new mw.Api( { parameters: { formatversion: 2 } } ),
title = mw.config.get( 'wgPageName' );
/**
* Get suggestions for wiki links.
*
* @param {string} search
* @param {number} namespace
* @param {boolean} subpage
* @return {Promise<string[]>}
*/
const linkSuggestFactory = ( search, namespace = 0, subpage = false ) => {
if ( subpage ) {
search = title + search;
}
return api.get( { action: 'opensearch', search, namespace, limit: 'max' } )
.then( ( [ , pages ] ) => {
if ( subpage ) {
const { length } = title;
return pages.map( ( page ) => page.slice( length ) );
}
return namespace === 0 ?
pages.map( ( page ) => page ) :
pages.map( ( page ) => new mw.Title( page ).getMainText() );
} ).catch( () => [] );
};
/**
* Autocompletion for page names.
*
* @param {CodeMirrorModeMediaWiki} mode
* @param {string} str
* @param {number} ns
* @return {Promise}
*/
const linkSuggest = ( mode, str, ns = 0 ) => {
const { config: { titleCompletion }, nsRegex } = mode;
if ( !titleCompletion || /[|{}<>[\]#]/.test( str ) ) {
return Promise.resolve( undefined );
}
let subpage = false,
search = str,
offset = 0;
if ( search.startsWith( '/' ) ) {
ns = 0;
subpage = true;
} else {
search = search.replace( /_/g, ' ' );
const mt = /^\s*/.exec( search );
[ { length: offset } ] = mt;
search = search.slice( offset );
if ( search.startsWith( ':' ) ) {
const [ { length } ] = /^:\s*/.exec( search );
offset += length;
search = search.slice( length );
ns = 0;
}
if ( !search ) {
return Promise.resolve( undefined );
}
const mt2 = nsRegex.exec( search );
if ( mt2 ) {
const [ { length }, prefix ] = mt2;
offset += length;
search = `${ prefix }:${ search.slice( length ) }`;
ns = 1;
}
}
const underscore = str.slice( offset ).includes( '_' );
return linkSuggestFactory( search, ns, subpage ).then( ( pages ) => ( {
offset,
options: pages.map( ( label ) => ( {
type: 'text',
label: underscore ? label.replace( / /g, '_' ) : label
} ) )
} ) );
};
/**
* Apply autocompletion for links.
*
* @param {boolean} closed
* @return {Function}
*/
const applyLinkCompletion = ( closed ) => ( view, completion, from, to ) => {
let { label } = completion;
const initial = label.charAt( 0 ).toLowerCase();
if ( view.state.sliceDoc( from, from + 1 ) === initial ) {
label = initial + label.slice( 1 );
}
view.dispatch( Object.assign(
insertCompletionText( view.state, label + ( closed ? '' : ']]' ), from, to ),
{
selection: EditorSelection.cursor( from + label.length ),
annotations: pickedCompletion.of( completion )
}
) );
};
/**
* Apply autocompletion for templates.
*
* @param {boolean} closed
* @return {Function}
*/
const applyTemplateCompletion = ( closed ) => ( view, completion, from, to ) => {
const { label } = completion;
view.dispatch( Object.assign(
insertCompletionText( view.state, label + ( closed ? '' : '}}' ), from, to ),
{
selection: EditorSelection.cursor( from + label.length ),
annotations: pickedCompletion.of( completion )
}
) );
};
/**
* Autocompletion for magic words, tag names, etc.
*
* @param {CodeMirrorModeMediaWiki} mode
* @return {CompletionSource}
*/
const completionSource = ( mode ) => ( context ) => {
const { state, pos, explicit } = context,
node = syntaxTree( state ).resolve( pos, -1 ),
types = new Set( node.name.split( '_' ) ),
isParserFunction = hasTag( types, 'parserFunctionName' ),
{ from } = node,
search = state.sliceDoc( from, pos );
if ( explicit || isParserFunction && search.includes( '#' ) ) {
const validFor = /^[^|{}<>[\]#]*$/;
if ( isParserFunction || hasTag( types, 'templateName' ) ) {
const options = search.includes( ':' ) ? [] : [ ...mode.functionSynonyms ],
apply = applyTemplateCompletion( /^\s*[|}]/.test( state.sliceDoc( pos ) ) );
return linkSuggest( mode, search, 10 )
.then( ( suggestions = { offset: 0, options: [] } ) => {
options.push( ...suggestions.options
.map( ( option ) => Object.assign( option, { apply } ) ) );
return options.length === 0 ?
null :
{ from: from + suggestions.offset, options, validFor };
} );
}
if ( hasTag( types, 'linkPageName' ) ) {
const apply = applyLinkCompletion( /^\s*[|\]]/.test( state.sliceDoc( pos ) ) );
return linkSuggest( mode, search ).then( ( suggestions ) => suggestions ? {
from: from + suggestions.offset,
options: suggestions.options
.map( ( option ) => Object.assign( option, { apply } ) ),
validFor
} : null );
}
}
if ( !hasTag( types, [
'comment',
'templateVariableName',
'templateName',
'linkPageName',
'linkToSection',
'extLink'
] ) ) {
let mt = context.matchBefore( /__(?:(?!__)[^\s<>[\]{}|#])*$/ );
if ( mt ) {
return {
from: mt.from,
options: mode.doubleUnderscore,
validFor: /^[^\s<>[\]{}|#]*$/
};
}
mt = context.matchBefore( /<\/?[a-z\d]*$/i );
const extTags = [ ...types ].filter( ( t ) => t.startsWith( 'mw-tag-' ) )
.map( ( s ) => s.slice( 7 ) );
if ( hasTag( types, 'extTag' ) ) {
let { prevSibling } = node;
while ( prevSibling &&
!prevSibling.name.split( '_' ).includes( mwModeConfig.tags.extTagName ) ) {
( { prevSibling } = prevSibling );
}
if ( prevSibling ) {
extTags.push(
state.sliceDoc( prevSibling.from, prevSibling.to ).trim().toLowerCase()
);
}
}
if ( mt && mt.to - mt.from > 1 ) {
const validFor = /^[a-z\d]*$/i;
if ( mt.text[ 1 ] === '/' ) {
const extTag = extTags[ extTags.length - 1 ],
closed = /^\s*>/.test( state.sliceDoc( pos ) ),
options = [
...mode.htmlTags.filter( ( { label } ) => !(
label in mwModeConfig.implicitlyClosedHtmlTags
) ),
...extTag ? [ { type: 'type', label: extTag, boost: 50 } ] : []
];
return {
from: mt.from + 2,
options: closed ? options : options.map( ( option ) => Object.assign( {
apply: `${ option.label }>`
}, option ) ),
validFor
};
}
return {
from: mt.from + 1,
options: [
...mode.htmlTags,
...mode.extTags.filter( ( { label } ) => !extTags.includes( label ) )
],
validFor
};
}
if ( !hasTag( types, [ 'linkText', 'extLinkText' ] ) ) {
mt = context.matchBefore( /(?:^|[^[])\[[a-z:/]+$/i );
if ( mt ) {
return {
from: mt.from + ( mt.text[ 1 ] === '[' ? 2 : 1 ),
options: mode.protocols,
validFor: /^[a-z:/]*$/i
};
}
}
}
return null;
};
module.exports = {
autocompleteExtension,
autocompleteKeymap,
completionSource
};