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.", "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 "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": { "CodeMirrorLineNumberingNamespaces": {
"value": null, "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.", "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, "lineNumbering": true,
"lineWrapping": true, "lineWrapping": true,
"specialChars": true, "specialChars": true,
"templateFolding": true "templateFolding": true,
"autocomplete": true
}, },
"description": "Defaults for CodeMirror user preferences. See https://w.wiki/BwzZ for more information.", "description": "Defaults for CodeMirror user preferences. See https://w.wiki/BwzZ for more information.",
"public": true "public": true
@ -284,7 +290,8 @@
"codemirror.mediawiki.js", "codemirror.mediawiki.js",
"codemirror.mediawiki.config.js", "codemirror.mediawiki.config.js",
"codemirror.mediawiki.bidiIsolation.js", "codemirror.mediawiki.bidiIsolation.js",
"codemirror.mediawiki.templateFolding.js" "codemirror.mediawiki.templateFolding.js",
"codemirror.mediawiki.autocomplete.js"
], ],
"styles": [ "styles": [
"codemirror.mediawiki.less", "codemirror.mediawiki.less",
@ -297,7 +304,8 @@
"messages": [ "messages": [
"codemirror-fold-template", "codemirror-fold-template",
"codemirror-prefs-bidiisolation", "codemirror-prefs-bidiisolation",
"codemirror-prefs-templatefolding" "codemirror-prefs-templatefolding",
"codemirror-prefs-autocomplete"
] ]
}, },
"ext.CodeMirror.v6.WikiEditor": { "ext.CodeMirror.v6.WikiEditor": {

View file

@ -12,6 +12,7 @@
"codemirror-prefs-enable": "Enable syntax highlighting for wikitext", "codemirror-prefs-enable": "Enable syntax highlighting for wikitext",
"codemirror-prefs-title": "Syntax highlighting preferences", "codemirror-prefs-title": "Syntax highlighting preferences",
"codemirror-prefs-templatefolding": "Enable folding of template parameters", "codemirror-prefs-templatefolding": "Enable folding of template parameters",
"codemirror-prefs-autocomplete": "Enable autocompletion",
"codemirror-prefs-bidiisolation": "Isolate bidirectional text", "codemirror-prefs-bidiisolation": "Isolate bidirectional text",
"codemirror-prefs-bracketmatching": "Enable bracket matching", "codemirror-prefs-bracketmatching": "Enable bracket matching",
"codemirror-prefs-linenumbering": "Show line numbers", "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-enable": "Used in user preferences as label for enabling syntax highlighting.",
"codemirror-prefs-title": "Syntax highlighting preferences", "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-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-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-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.", "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' ), 'defaultPreferences' => $mwConfig->get( 'CodeMirrorDefaultPreferences' ),
'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ), 'lineNumberingNamespaces' => $mwConfig->get( 'CodeMirrorLineNumberingNamespaces' ),
'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ), 'templateFoldingNamespaces' => $mwConfig->get( 'CodeMirrorTemplateFoldingNamespaces' ),
'autocompleteNamespaces' => $mwConfig->get( 'CodeMirrorAutocompleteNamespaces' ),
'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ), 'pluginModules' => $registry->getAttribute( 'CodeMirrorPluginModules' ),
'tagModes' => $tagModes, 'tagModes' => $tagModes,
'tags' => array_fill_keys( $tagNames, true ), 'tags' => array_fill_keys( $tagNames, true ),

20
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": { "": {
"name": "codemirror", "name": "codemirror",
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "6.12.0",
"@codemirror/commands": "6.2.5", "@codemirror/commands": "6.2.5",
"@codemirror/language": "6.9.3", "@codemirror/language": "6.9.3",
"@codemirror/search": "6.5.6", "@codemirror/search": "6.5.6",
@ -499,6 +500,25 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@codemirror/commands": {
"version": "6.2.5", "version": "6.2.5",
"dev": true, "dev": true,

View file

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

View file

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

View file

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

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 { const {
CompletionSource,
HighlightStyle, HighlightStyle,
LanguageSupport, LanguageSupport,
StreamLanguage, StreamLanguage,
StreamParser, StreamParser,
StringStream, StringStream,
Tag, Tag,
ensureSyntaxTree,
syntaxHighlighting syntaxHighlighting
} = require( 'ext.CodeMirror.v6.lib' ); } = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' ); const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' ); const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' );
const templateFoldingExtension = require( './codemirror.mediawiki.templateFolding.js' ); const templateFoldingExtension = require( './codemirror.mediawiki.templateFolding.js' );
const autocompleteExtension = require( './codemirror.mediawiki.autocomplete.js' );
/** /**
* MediaWiki language support for CodeMirror 6. * MediaWiki language support for CodeMirror 6.
@ -35,8 +38,7 @@ class CodeMirrorModeMediaWiki {
*/ */
constructor( config ) { constructor( config ) {
this.config = config; this.config = config;
this.urlProtocols = new RegExp( `^(?:${ config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
this.urlProtocols = new RegExp( `^(?:${ this.config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
this.isBold = false; this.isBold = false;
this.wasBold = false; this.wasBold = false;
this.isItalic = false; this.isItalic = false;
@ -51,7 +53,25 @@ class CodeMirrorModeMediaWiki {
this.registerGroundTokens(); this.registerGroundTokens();
// Dynamically register any tags that aren't already in CodeMirrorModeMediaWikiConfig // 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; 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 * @see https://codemirror.net/docs/ref/#language.StreamParser
* @return {StreamParser} * @return {StreamParser}
@ -1257,7 +1358,18 @@ class CodeMirrorModeMediaWiki {
* @return {Object<Tag>} * @return {Object<Tag>}
* @private * @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. * @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. * 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.templateFolding=true] Enable template folding.
* @param {boolean} [config.autocomplete=true] Enable autocompletion.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests. * @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport} * @return {LanguageSupport}
* @stable to call * @stable to call
@ -1292,6 +1405,9 @@ const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) =>
if ( config.templateFolding !== false ) { if ( config.templateFolding !== false ) {
cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view ); cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view );
} }
if ( config.autocomplete !== false ) {
cm.preferences.registerExtension( 'autocomplete', autocompleteExtension, cm.view );
}
if ( config.bidiIsolation ) { if ( config.bidiIsolation ) {
cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view ); cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view );
} }

View file

@ -16,7 +16,7 @@ const {
unfoldAll, unfoldAll,
unfoldEffect unfoldEffect
} = require( 'ext.CodeMirror.v6.lib' ); } = 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 `}}`) * Check if a SyntaxNode is a template bracket (`{{` or `}}`)
@ -25,7 +25,7 @@ const modeConfig = require( './codemirror.mediawiki.config.js' );
* @return {boolean} * @return {boolean}
* @private * @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 (`|`) * Check if a SyntaxNode is a template delimiter (`|`)
* *
@ -33,7 +33,7 @@ const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.t
* @return {boolean} * @return {boolean}
* @private * @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 * 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} * @return {boolean}
* @private * @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 * 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. // Some preferences can be set per-namespace through wiki configuration.
// Values are an array of namespace IDs, [] to disable everywhere, // Values are an array of namespace IDs, [] to disable everywhere,
// or null to enable everywhere. // or null to enable everywhere.
const namespacePrefs = [ 'lineNumbering', 'templateFolding' ]; const namespacePrefs = [ 'lineNumbering', 'templateFolding', 'autocomplete' ];
if ( namespacePrefs.includes( prefName ) ) { if ( namespacePrefs.includes( prefName ) ) {
const namespaces = mw.config.get( 'extCodeMirrorConfig' )[ prefName + 'Namespaces' ]; const namespaces = mw.config.get( 'extCodeMirrorConfig' )[ prefName + 'Namespaces' ];
return !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ); 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 cm = new CodeMirror( textarea );
const mwLang = mediaWikiLang( const mwLang = mediaWikiLang(
{ bidiIsolation: true }, { bidiIsolation: true },
{ tags: { ref: true } } {
doubleUnderscore: [ {}, {} ],
functionSynonyms: [ {}, {} ],
tags: { ref: true },
urlProtocols: 'http\\:\\/\\/'
}
); );
cm.initialize( [ ...cm.defaultExtensions, mwLang ] ); cm.initialize( [ ...cm.defaultExtensions, mwLang ] );
// Normally ran by mw.hook, but we don't mock the hook system in the Jest tests. // 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:', urlProtocols: 'ftp://|https://|news:',
doubleUnderscore: [ { doubleUnderscore: [ {
__notoc__: 'notoc' __notoc__: 'notoc'
} ], }, {} ],
functionSynonyms: [ {}, { functionSynonyms: [ {}, {
'!': '!', '!': '!',
'מיון רגיל': 'defaultsort' 'מיון רגיל': 'defaultsort'

View file

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