CodeMirrorModeMediaWiki: autocompletion

Autocomplete magic words, tag names and url protocols. This patch also enables block comment using `<!-- -->`.

Bug: T95100
Change-Id: If37da956ac1eb945b96753e6728c0247b1a68b66
This commit is contained in:
bhsd 2024-04-29 10:30:02 +08:00
parent 6cfde8a849
commit 197b5649ff
16 changed files with 2214 additions and 33 deletions

View file

@ -33,6 +33,11 @@
"description": "List of namespace IDs where template folding should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere.",
"public": true
},
"CodeMirrorAutocompleteNamespaces": {
"value": null,
"description": "List of namespace IDs where autocompletion should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere.",
"public": true
},
"CodeMirrorLineNumberingNamespaces": {
"value": null,
"description": "List of namespace IDs where line numbering should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere.",
@ -46,7 +51,8 @@
"lineNumbering": true,
"lineWrapping": true,
"specialChars": true,
"templateFolding": true
"templateFolding": true,
"autocomplete": true
},
"description": "Defaults for CodeMirror user preferences. See https://w.wiki/BwzZ for more information.",
"public": true
@ -284,7 +290,8 @@
"codemirror.mediawiki.js",
"codemirror.mediawiki.config.js",
"codemirror.mediawiki.bidiIsolation.js",
"codemirror.mediawiki.templateFolding.js"
"codemirror.mediawiki.templateFolding.js",
"codemirror.mediawiki.autocomplete.js"
],
"styles": [
"codemirror.mediawiki.less",
@ -297,7 +304,8 @@
"messages": [
"codemirror-fold-template",
"codemirror-prefs-bidiisolation",
"codemirror-prefs-templatefolding"
"codemirror-prefs-templatefolding",
"codemirror-prefs-autocomplete"
]
},
"ext.CodeMirror.v6.WikiEditor": {

View file

@ -12,6 +12,7 @@
"codemirror-prefs-enable": "Enable syntax highlighting for wikitext",
"codemirror-prefs-title": "Syntax highlighting preferences",
"codemirror-prefs-templatefolding": "Enable folding of template parameters",
"codemirror-prefs-autocomplete": "Enable autocompletion",
"codemirror-prefs-bidiisolation": "Isolate bidirectional text",
"codemirror-prefs-bracketmatching": "Enable bracket matching",
"codemirror-prefs-linenumbering": "Show line numbers",

View file

@ -17,6 +17,7 @@
"codemirror-prefs-enable": "Used in user preferences as label for enabling syntax highlighting.",
"codemirror-prefs-title": "Syntax highlighting preferences",
"codemirror-prefs-templatefolding": "Label for the option to enable folding of template parameters in the CodeMirror preferences panel.",
"codemirror-prefs-autocomplete": "Label for the option to enable autocompletion in the CodeMirror preferences panel.",
"codemirror-prefs-bidiisolation": "Label for the option to enable bidirectional text isolation in the CodeMirror preferences panel.",
"codemirror-prefs-bracketmatching": "Label for the option to enable bracket matching in the CodeMirror preferences panel.",
"codemirror-prefs-linenumbering": "Label for the option to show line numbers in the CodeMirror preferences panel.",

View file

@ -62,6 +62,7 @@ class DataScript {
'defaultPreferences' => $mwConfig->get( 'CodeMirrorDefaultPreferences' ),
'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ),
'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ),
'autocompleteNamespaces' => $mwConfig->get( 'CodeMirrorAutocompleteNamespaces' ),
'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ),
'tagModes' => $tagModes,
'tags' => array_fill_keys( $tagNames, true ),

20
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": {
"name": "codemirror",
"devDependencies": {
"@codemirror/autocomplete": "6.12.0",
"@codemirror/commands": "6.2.5",
"@codemirror/language": "6.9.3",
"@codemirror/search": "6.5.6",
@ -499,6 +500,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/autocomplete": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz",
"integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.2.5",
"dev": true,

View file

@ -18,6 +18,7 @@
"node": "18.20.4"
},
"devDependencies": {
"@codemirror/autocomplete": "6.12.0",
"@codemirror/commands": "6.2.5",
"@codemirror/language": "6.9.3",
"@codemirror/search": "6.5.6",

View file

@ -2,6 +2,7 @@
* This file is managed by Rollup and bundles all the CodeMirror dependencies
* into the single file resources/lib/codemirror6.bundle.dist.js.
*/
import '@codemirror/autocomplete';
import '@codemirror/commands';
import '@codemirror/language';
import '@codemirror/search';
@ -10,6 +11,7 @@ import '@codemirror/view';
import '@lezer/highlight';
/* eslint-disable es-x/no-export-ns-from */
export * from '@codemirror/autocomplete';
export * from '@codemirror/commands';
export * from '@codemirror/language';
export * from '@codemirror/search';

View file

@ -35,9 +35,10 @@
line-height: 1.2;
padding: 0 1px;
opacity: 0.6;
}
.cm-tooltip-fold:hover {
&:hover {
opacity: 1;
}
}
.cm-editor .cm-foldPlaceholder {

View file

@ -0,0 +1,20 @@
const {
autocompletion,
acceptCompletion,
keymap
} = require( 'ext.CodeMirror.v6.lib' );
/**
* CodeMirror extension providing
* autocompletion
* for the MediaWiki mode. This automatically applied when using {@link CodeMirrorModeMediaWiki}.
*
* @module CodeMirrorAutocomplete
* @type {Extension}
*/
const autocompleteExtension = [
autocompletion( { defaultKeymap: true } ),
keymap.of( [ { key: 'Tab', run: acceptCompletion } ] )
];
module.exports = autocompleteExtension;

View file

@ -1,15 +1,18 @@
const {
CompletionSource,
HighlightStyle,
LanguageSupport,
StreamLanguage,
StreamParser,
StringStream,
Tag,
ensureSyntaxTree,
syntaxHighlighting
} = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' );
const templateFoldingExtension = require( './codemirror.mediawiki.templateFolding.js' );
const autocompleteExtension = require( './codemirror.mediawiki.autocomplete.js' );
/**
* MediaWiki language support for CodeMirror 6.
@ -35,8 +38,7 @@ class CodeMirrorModeMediaWiki {
*/
constructor( config ) {
this.config = config;
this.urlProtocols = new RegExp( `^(?:${ this.config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
this.urlProtocols = new RegExp( `^(?:${ config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
this.isBold = false;
this.wasBold = false;
this.isItalic = false;
@ -51,7 +53,25 @@ class CodeMirrorModeMediaWiki {
this.registerGroundTokens();
// Dynamically register any tags that aren't already in CodeMirrorModeMediaWikiConfig
Object.keys( this.config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );
Object.keys( config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );
this.functionSynonyms = [
...Object.keys( config.functionSynonyms[ 0 ] )
.map( ( label ) => ( { type: 'function', label } ) ),
...Object.keys( config.functionSynonyms[ 1 ] )
.map( ( label ) => ( { type: 'constant', label } ) )
];
this.doubleUnderscore = [
...Object.keys( config.doubleUnderscore[ 0 ] ),
...Object.keys( config.doubleUnderscore[ 1 ] )
].map( ( label ) => ( { type: 'constant', label } ) );
const extTags = Object.keys( config.tags );
this.extTags = extTags.map( ( label ) => ( { type: 'type', label } ) );
this.htmlTags = Object.keys( mwModeConfig.permittedHtmlTags )
.filter( ( tag ) => !extTags.includes( tag ) )
.map( ( label ) => ( { type: 'type', label } ) );
this.protocols = config.urlProtocols.split( '|' )
.map( ( label ) => ( { type: 'namespace', label: label.replace( /\\([:/])/g, '$1' ) } ) );
}
/**
@ -1097,6 +1117,87 @@ class CodeMirrorModeMediaWiki {
this.wasItalic = this.isItalic;
}
/**
* Autocompletion for magic words and tag names.
*
* @return {CompletionSource}
* @private
*/
get completionSource() {
return ( context ) => {
const { state, pos, explicit } = context,
tree = ensureSyntaxTree( state, pos ),
node = tree && tree.resolve( pos, -1 );
if ( !node ) {
return null;
}
const types = new Set( node.name.split( '_' ) ),
isParserFunction = types.has( mwModeConfig.tags.parserFunctionName ),
{ from, to } = node,
search = state.sliceDoc( from, to );
if ( explicit || isParserFunction && search.includes( '#' ) ) {
const validFor = /^[^|{}<>[\]#]*$/;
if ( isParserFunction || types.has( mwModeConfig.tags.templateName ) && !search.includes( ':' ) ) {
return {
from,
options: this.functionSynonyms,
validFor
};
}
} else if ( !types.has( mwModeConfig.tags.comment ) &&
!types.has( mwModeConfig.tags.templateVariableName ) &&
!types.has( mwModeConfig.tags.templateName ) &&
!types.has( mwModeConfig.tags.linkPageName ) &&
!types.has( mwModeConfig.tags.linkToSection ) &&
!types.has( mwModeConfig.tags.extLink )
) {
let mt = context.matchBefore( /__(?:(?!__)[^\s<>[\]{}|#])*$/ );
if ( mt ) {
return {
from: mt.from,
options: this.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 ( mt && mt.to - mt.from > 1 ) {
const validFor = /^[a-z\d]*$/i;
if ( mt.text[ 1 ] === '/' ) {
const extTag = extTags[ extTags.length - 1 ],
options = [
...this.htmlTags.filter( ( { label } ) => !(
label in mwModeConfig.implicitlyClosedHtmlTags
) ),
...extTag ? [ { type: 'type', label: extTag, boost: 50 } ] : []
];
return { from: mt.from + 2, options, validFor };
}
return {
from: mt.from + 1,
options: [
...this.htmlTags,
...this.extTags.filter( ( { label } ) => !extTags.includes( label ) )
],
validFor
};
}
if ( !types.has( mwModeConfig.tags.linkText ) &&
!types.has( mwModeConfig.tags.extLinkText ) ) {
mt = context.matchBefore( /(?:^|[^[])\[[a-z:/]+$/i );
if ( mt ) {
return {
from: mt.from + ( mt.text[ 1 ] === '[' ? 2 : 1 ),
options: this.protocols,
validFor: /^[a-z:/]*$/i
};
}
}
}
return null;
};
}
/**
* @see https://codemirror.net/docs/ref/#language.StreamParser
* @return {StreamParser}
@ -1257,7 +1358,18 @@ class CodeMirrorModeMediaWiki {
* @return {Object<Tag>}
* @private
*/
tokenTable: this.tokenTable
tokenTable: this.tokenTable,
/**
* @see https://codemirror.net/docs/ref/#language.StreamParser.languageData
* @return {Object<any>}
* @private
*/
languageData: {
// TODO: Rewrite the comment command using jQuery.textSelection
commentTokens: { block: { open: '<!--', close: '-->' } },
autocomplete: this.completionSource
}
};
}
}
@ -1271,6 +1383,7 @@ class CodeMirrorModeMediaWiki {
* @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
* This should generally always be enabled on RTL pages, but it comes with a performance cost.
* @param {boolean} [config.templateFolding=true] Enable template folding.
* @param {boolean} [config.autocomplete=true] Enable autocompletion.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport}
* @stable to call
@ -1292,6 +1405,9 @@ const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) =>
if ( config.templateFolding !== false ) {
cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view );
}
if ( config.autocomplete !== false ) {
cm.preferences.registerExtension( 'autocomplete', autocompleteExtension, cm.view );
}
if ( config.bidiIsolation ) {
cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view );
}

View file

@ -16,7 +16,7 @@ const {
unfoldAll,
unfoldEffect
} = require( 'ext.CodeMirror.v6.lib' );
const modeConfig = require( './codemirror.mediawiki.config.js' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
/**
* Check if a SyntaxNode is a template bracket (`{{` or `}}`)
@ -25,7 +25,7 @@ const modeConfig = require( './codemirror.mediawiki.config.js' );
* @return {boolean}
* @private
*/
const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateBracket ),
const isBracket = ( node ) => node.name.split( '_' ).includes( mwModeConfig.tags.templateBracket ),
/**
* Check if a SyntaxNode is a template delimiter (`|`)
*
@ -33,7 +33,7 @@ const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.t
* @return {boolean}
* @private
*/
isDelimiter = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateDelimiter ),
isDelimiter = ( node ) => node.name.split( '_' ).includes( mwModeConfig.tags.templateDelimiter ),
/**
* Check if a SyntaxNode is part of a template, except for the brackets
*
@ -41,7 +41,7 @@ const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.t
* @return {boolean}
* @private
*/
isTemplate = ( node ) => /-template[a-z\d-]+ground/u.test( node.name ) && !isBracket( node ),
isTemplate = ( node ) => /-template[a-z\d-]+ground/.test( node.name ) && !isBracket( node ),
/**
* Update the stack of opening (+) or closing (-) brackets
*

View file

@ -182,7 +182,7 @@ class CodeMirrorPreferences extends CodeMirrorPanel {
// Some preferences can be set per-namespace through wiki configuration.
// Values are an array of namespace IDs, [] to disable everywhere,
// or null to enable everywhere.
const namespacePrefs = [ 'lineNumbering', 'templateFolding' ];
const namespacePrefs = [ 'lineNumbering', 'templateFolding', 'autocomplete' ];
if ( namespacePrefs.includes( prefName ) ) {
const namespaces = mw.config.get( 'extCodeMirrorConfig' )[ prefName + 'Namespaces' ];
return !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) );

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,12 @@ document.body.appendChild( textarea );
const cm = new CodeMirror( textarea );
const mwLang = mediaWikiLang(
{ bidiIsolation: true },
{ tags: { ref: true } }
{
doubleUnderscore: [ {}, {} ],
functionSynonyms: [ {}, {} ],
tags: { ref: true },
urlProtocols: 'http\\:\\/\\/'
}
);
cm.initialize( [ ...cm.defaultExtensions, mwLang ] );
// Normally ran by mw.hook, but we don't mock the hook system in the Jest tests.

View file

@ -184,7 +184,7 @@ const mwLang = mediaWikiLang( {}, {
urlProtocols: 'ftp://|https://|news:',
doubleUnderscore: [ {
__notoc__: 'notoc'
} ],
}, {} ],
functionSynonyms: [ {}, {
'!': '!',
'מיון רגיל': 'defaultsort'

View file

@ -17,6 +17,7 @@ class DataScriptTest extends \MediaWikiIntegrationTestCase {
$this->assertStringContainsString( '"extCodeMirrorConfig":', $script );
$this->assertStringContainsString( '"lineNumberingNamespaces":', $script );
$this->assertStringContainsString( '"templateFoldingNamespaces":', $script );
$this->assertStringContainsString( '"autocompleteNamespaces":', $script );
$this->assertStringContainsString( '"pluginModules":', $script );
$this->assertStringContainsString( '"tagModes":', $script );
$this->assertStringContainsString( '"tags":', $script );