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. * Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov. * * @module CodeMirrorModeMediaWiki * * @example * mw.loader.using( [ * 'ext.CodeMirror.v6', * 'ext.CodeMirror.v6.mode.mediawiki' * ] ).then( ( require ) => { * const CodeMirror = require( 'ext.CodeMirror.v6' ); * const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' ); * const cm = new CodeMirror( myTextarea ); * cm.initialize( [ cm.defaultExtensions, mediawikiLang() ] ); * } ); */ class CodeMirrorModeMediaWiki { /** * @param {Object} config MediaWiki configuration as generated by DataScript.php * @internal */ constructor( config ) { this.config = config; this.urlProtocols = new RegExp( `^(?:${ config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' ); this.isBold = false; this.wasBold = false; this.isItalic = false; this.wasItalic = false; this.firstSingleLetterWord = null; this.firstMultiLetterWord = null; this.firstSpace = null; this.oldStyle = null; this.tokens = []; this.oldTokens = []; this.tokenTable = mwModeConfig.tokenTable; this.registerGroundTokens(); // Dynamically register any tags that aren't already in CodeMirrorModeMediaWikiConfig 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' ) } ) ); } /** * Register the ground tokens. These aren't referenced directly in the StreamParser, nor do * they have a parent Tag, so we don't need them as constants like we do for other tokens. * See this.makeLocalStyle() for how these tokens are used. * * @private */ registerGroundTokens() { [ 'mw-ext-ground', 'mw-ext-link-ground', 'mw-ext2-ground', 'mw-ext2-link-ground', 'mw-ext3-ground', 'mw-ext3-link-ground', 'mw-link-ground', 'mw-template-ext-ground', 'mw-template-ext-link-ground', 'mw-template-ext2-ground', 'mw-template-ext2-link-ground', 'mw-template-ext3-ground', 'mw-template-ext3-link-ground', 'mw-template-ground', 'mw-template-link-ground', 'mw-template2-ext-ground', 'mw-template2-ext-link-ground', 'mw-template2-ext2-ground', 'mw-template2-ext2-link-ground', 'mw-template2-ext3-ground', 'mw-template2-ext3-link-ground', 'mw-template2-ground', 'mw-template2-link-ground', 'mw-template3-ext-ground', 'mw-template3-ext-link-ground', 'mw-template3-ext2-ground', 'mw-template3-ext2-link-ground', 'mw-template3-ext3-ground', 'mw-template3-ext3-link-ground', 'mw-template3-ground', 'mw-template3-link-ground' ].forEach( ( ground ) => mwModeConfig.addToken( ground ) ); } eatHtmlEntity( stream, style ) { let ok; if ( stream.eat( '#' ) ) { if ( stream.eat( 'x' ) ) { ok = stream.eatWhile( /[a-fA-F\d]/ ) && stream.eat( ';' ); } else { ok = stream.eatWhile( /[\d]/ ) && stream.eat( ';' ); } } else { ok = stream.eatWhile( /[\w.\-:]/ ) && stream.eat( ';' ); } if ( ok ) { return mwModeConfig.tags.htmlEntity; } return style; } isNested( state ) { return state.nExt > 0 || state.nTemplate > 0 || state.nLink > 0; } makeStyle( style, state, endGround ) { if ( this.isBold || state.nDt > 0 ) { style += ' ' + mwModeConfig.tags.strong; } if ( this.isItalic ) { style += ' ' + mwModeConfig.tags.em; } return this.makeLocalStyle( style, state, endGround ); } makeLocalStyle( style, state, endGround ) { let ground = ''; switch ( state.nTemplate ) { case 0: break; case 1: ground += '-template'; break; case 2: ground += '-template2'; break; default: ground += '-template3'; break; } switch ( state.nExt ) { case 0: break; case 1: ground += '-ext'; break; case 2: ground += '-ext2'; break; default: ground += '-ext3'; break; } if ( state.nLink > 0 ) { ground += '-link'; } if ( ground !== '' ) { style = `mw${ ground }-ground ${ style }`; } if ( endGround ) { state[ endGround ]--; } return style.trim(); } eatBlock( style, terminator, consumeLast ) { return ( stream, state ) => { if ( stream.skipTo( terminator ) ) { if ( consumeLast !== false ) { stream.match( terminator ); } state.tokenize = state.stack.pop(); } else { stream.skipToEnd(); } return this.makeLocalStyle( style, state ); }; } eatEnd( style ) { return ( stream, state ) => { stream.skipToEnd(); state.tokenize = state.stack.pop(); return this.makeLocalStyle( style, state ); }; } eatChar( char, style ) { return ( stream, state ) => { state.tokenize = state.stack.pop(); if ( stream.eat( char ) ) { return this.makeLocalStyle( style, state ); } return this.makeLocalStyle( mwModeConfig.tags.error, state ); }; } eatSectionHeader( count ) { return ( stream, state ) => { if ( stream.match( /^[^&<[{~]+/ ) ) { if ( stream.eol() ) { stream.backUp( count ); state.tokenize = this.eatEnd( mwModeConfig.tags.sectionHeader ); } else if ( stream.match( /^.*?=)/, false ) ) { // T171074: handle trailing comments stream.backUp( count ); state.tokenize = this.eatBlock( mwModeConfig.tags.sectionHeader, '/ ) ) { return this.makeLocalStyle( mwModeConfig.tags.comment, state ); } if ( haveAte && stream.sol() ) { // @todo error message state.nTemplate--; state.tokenize = state.stack.pop(); return; } if ( stream.match( /^[\s\u00a0]*[^\s\u00a0|}<{&~]+/ ) ) { state.tokenize = this.eatTemplatePageName( true ); return this.makeLocalStyle( mwModeConfig.tags.templateName, state ); } else if ( stream.eatSpace() ) { if ( stream.eol() === true ) { return this.makeLocalStyle( mwModeConfig.tags.templateName, state ); } return this.makeLocalStyle( mwModeConfig.tags.templateName, state ); } return this.eatWikiText( mwModeConfig.tags.templateName )( stream, state ); }; } eatTemplateArgument( expectArgName ) { return ( stream, state ) => { if ( expectArgName && stream.eatWhile( /[^=|}{[<&~]/ ) ) { if ( stream.eat( '=' ) ) { state.tokenize = this.eatTemplateArgument( false ); return this.makeLocalStyle( mwModeConfig.tags.templateArgumentName, state ); } return this.makeLocalStyle( mwModeConfig.tags.template, state ); } else if ( stream.eatWhile( /[^|}{[<&~]/ ) ) { return this.makeLocalStyle( mwModeConfig.tags.template, state ); } else if ( stream.eat( '|' ) ) { state.tokenize = this.eatTemplateArgument( true ); return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state ); } else if ( stream.match( '}}' ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' ); } return this.eatWikiText( mwModeConfig.tags.template )( stream, state ); }; } eatExternalLinkProtocol( chars ) { return ( stream, state ) => { while ( chars > 0 ) { chars--; stream.next(); } if ( stream.eol() ) { state.nLink--; // @todo error message state.tokenize = state.stack.pop(); } else { state.tokenize = this.inExternalLink.bind( this ); } return this.makeLocalStyle( mwModeConfig.tags.extLinkProtocol, state ); }; } inExternalLink( stream, state ) { if ( stream.sol() ) { state.nLink--; // @todo error message state.tokenize = state.stack.pop(); return; } if ( stream.match( /^[\s\u00a0]*\]/ ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' ); } if ( stream.eatSpace() ) { state.tokenize = this.inExternalLinkText.bind( this ); return this.makeStyle( '', state ); } if ( stream.match( /^[^\s\u00a0\]{&~']+/ ) || stream.eatSpace() ) { if ( stream.peek() === '\'' ) { if ( stream.match( '\'\'', false ) ) { state.tokenize = this.inExternalLinkText.bind( this ); } else { stream.next(); } } return this.makeStyle( mwModeConfig.tags.extLink, state ); } return this.eatWikiText( mwModeConfig.tags.extLink )( stream, state ); } inExternalLinkText( stream, state ) { if ( stream.sol() ) { state.nLink--; // @todo error message state.tokenize = state.stack.pop(); return; } if ( stream.eat( ']' ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' ); } if ( stream.match( /^[^'\]{&~<]+/ ) ) { return this.makeStyle( mwModeConfig.tags.extLinkText, state ); } return this.eatWikiText( mwModeConfig.tags.extLinkText )( stream, state ); } inLink( stream, state ) { if ( stream.sol() ) { state.nLink--; // @todo error message state.tokenize = state.stack.pop(); return; } if ( stream.match( /^[\s\u00a0]*#[\s\u00a0]*/ ) ) { state.tokenize = this.inLinkToSection.bind( this ); return this.makeLocalStyle( mwModeConfig.tags.link, state ); } if ( stream.match( /^[\s\u00a0]*\|[\s\u00a0]*/ ) ) { state.tokenize = this.eatLinkText(); return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state ); } if ( stream.match( /^[\s\u00a0]*\]\]/ ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' ); } if ( stream.match( /^[\s\u00a0]*[^\s\u00a0#|\]&~{]+/ ) || stream.eatSpace() ) { return this.makeStyle( `${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`, state ); } return this.eatWikiText( `${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }` )( stream, state ); } inLinkToSection( stream, state ) { if ( stream.sol() ) { // @todo error message state.nLink--; state.tokenize = state.stack.pop(); return; } // FIXME '{{' breaks links, example: [[z{{page]] if ( stream.match( /^[^|\]&~{}]+/ ) ) { return this.makeLocalStyle( mwModeConfig.tags.linkToSection, state ); } if ( stream.eat( '|' ) ) { state.tokenize = this.eatLinkText(); return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state ); } if ( stream.match( ']]' ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' ); } return this.eatWikiText( mwModeConfig.tags.linkToSection )( stream, state ); } eatLinkText() { let linkIsBold, linkIsItalic; return ( stream, state ) => { let tmpstyle; if ( stream.match( ']]' ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' ); } if ( stream.match( '\'\'\'' ) ) { linkIsBold = !linkIsBold; return this.makeLocalStyle( `${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`, state ); } if ( stream.match( '\'\'' ) ) { linkIsItalic = !linkIsItalic; return this.makeLocalStyle( `${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`, state ); } tmpstyle = mwModeConfig.tags.linkText; if ( linkIsBold ) { tmpstyle += ' ' + mwModeConfig.tags.strong; } if ( linkIsItalic ) { tmpstyle += ' ' + mwModeConfig.tags.em; } if ( stream.match( /^[^'\]{&~<]+/ ) ) { return this.makeStyle( tmpstyle, state ); } return this.eatWikiText( tmpstyle )( stream, state ); }; } eatTagName( chars, isCloseTag, isHtmlTag ) { return ( stream, state ) => { let name = ''; while ( chars > 0 ) { chars--; name = name + stream.next(); } stream.eatSpace(); name = name.toLowerCase(); if ( isHtmlTag ) { if ( isCloseTag && !mwModeConfig.implicitlyClosedHtmlTags[ name ] ) { state.tokenize = this.eatChar( '>', mwModeConfig.tags.htmlTagBracket ); } else { state.tokenize = this.eatHtmlTagAttribute( name ); } return this.makeLocalStyle( mwModeConfig.tags.htmlTagName, state ); } // it is the extension tag if ( isCloseTag ) { state.tokenize = this.eatChar( '>', `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }` ); } else { state.tokenize = this.eatExtTagAttribute( name ); } return this.makeLocalStyle( `${ mwModeConfig.tags.extTagName } mw-ext-${ name }`, state ); }; } eatHtmlTagAttribute( name ) { return ( stream, state ) => { if ( stream.match( /^(?:"[^<">]*"|'[^<'>]*'|[^>/<{&~])+/ ) ) { return this.makeLocalStyle( mwModeConfig.tags.htmlTagAttribute, state ); } if ( stream.eat( '>' ) ) { if ( !( name in mwModeConfig.implicitlyClosedHtmlTags ) ) { state.inHtmlTag.push( name ); } state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state ); } if ( stream.match( '/>' ) ) { state.tokenize = state.stack.pop(); return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state ); } return this.eatWikiText( mwModeConfig.tags.htmlTagAttribute )( stream, state ); }; } eatNowiki() { return ( stream ) => { if ( stream.match( /^[^&]+/ ) ) { return ''; } // eat & stream.next(); return this.eatHtmlEntity( stream, '' ); }; } eatExtTagAttribute( name ) { return ( stream, state ) => { if ( stream.match( /^(?:"[^">]*"|'[^'>]*'|[^>/<{&~])+/ ) ) { return this.makeLocalStyle( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }`, state ); } if ( stream.eat( '>' ) ) { state.extName = name; // FIXME: remove nowiki and pre from TagModes in extension.json after CM6 upgrade // leverage the tagModes system for and
				if ( name === 'nowiki' || name === 'pre' ) {
					// There's no actual processing within these tags (apart from HTML entities),
					// so startState and copyState can be no-ops.
					state.extMode = {
						startState: () => {},
						copyState: () => {},
						token: this.eatNowiki()
					};
				} else if ( name in this.config.tagModes ) {
					const mode = this.config.tagModes[ name ];
					if ( mode === 'mediawiki' || mode === 'text/mediawiki' ) {
						state.extMode = this.mediawiki;
						state.extState = state.extMode.startState();
					}
				}

				state.tokenize = this.eatExtTagArea( name );
				return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
			}
			if ( stream.match( '/>' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
			}
			return this.eatWikiText( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }` )( stream, state );
		};
	}

	eatExtTagArea( name ) {
		return ( stream, state ) => {
			const from = stream.pos,

				pattern = new RegExp( ``, 'i' ),
				m = pattern.exec( from ? stream.string.slice( from ) : stream.string );
			let origString = false,
				to;

			if ( m ) {
				if ( m.index === 0 ) {
					state.tokenize = this.eatExtCloseTag( name );
					state.extName = false;
					if ( state.extMode !== false ) {
						state.extMode = false;
						state.extState = false;
					}
					return state.tokenize( stream, state );
				}
				to = m.index + from;
				origString = stream.string;
				stream.string = origString.slice( 0, to );
			}

			state.stack.push( state.tokenize );
			state.tokenize = this.eatExtTokens( origString );
			return state.tokenize( stream, state );
		};
	}

	eatExtCloseTag( name ) {
		return ( stream, state ) => {
			stream.next(); // eat <
			stream.next(); // eat /
			state.tokenize = this.eatTagName( name.length, true, false );
			return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
		};
	}

	eatExtTokens( origString ) {
		return ( stream, state ) => {
			let ret;
			if ( state.extMode === false ) {
				ret = mwModeConfig.tags.extTag;
				stream.skipToEnd();
			} else {
				ret = `mw-tag-${ state.extName } ` +
					state.extMode.token( stream, state.extState, origString === false );
			}
			if ( stream.eol() ) {
				if ( origString !== false ) {
					stream.string = origString;
				}
				state.tokenize = state.stack.pop();
			}
			return this.makeLocalStyle( ret, state );
		};
	}

	eatStartTable( stream, state ) {
		stream.match( '{|' );
		stream.eatSpace();
		state.tokenize = this.inTableDefinition.bind( this );
		return mwModeConfig.tags.tableBracket;
	}

	inTableDefinition( stream, state ) {
		if ( stream.sol() ) {
			state.tokenize = this.inTable.bind( this );
			return this.inTable( stream, state );
		}
		return this.eatWikiText( mwModeConfig.tags.tableDefinition )( stream, state );
	}

	inTable( stream, state ) {
		if ( stream.sol() ) {
			stream.eatSpace();
			if ( stream.eat( '|' ) ) {
				if ( stream.eat( '-' ) ) {
					stream.eatSpace();
					state.tokenize = this.inTableDefinition.bind( this );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( stream.eat( '+' ) ) {
					stream.eatSpace();
					state.tokenize = this.eatTableRow( true, false, true );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( stream.eat( '}' ) ) {
					state.tokenize = state.stack.pop();
					return this.makeLocalStyle( mwModeConfig.tags.tableBracket, state );
				}
				stream.eatSpace();
				state.tokenize = this.eatTableRow( true, false );
				return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
			}
			if ( stream.eat( '!' ) ) {
				stream.eatSpace();
				state.tokenize = this.eatTableRow( true, true );
				return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
			}
		}
		return this.eatWikiText( '' )( stream, state );
	}

	// isStart actually means whether there may be attributes */
	eatTableRow( isStart, isHead, isCaption ) {
		let tag = '';
		if ( isCaption ) {
			tag = mwModeConfig.tags.tableCaption;
		} else if ( isHead ) {
			tag = mwModeConfig.tags.strong;
		}
		return ( stream, state ) => {
			if ( stream.sol() ) {
				if ( stream.match( /^[\s\u00a0]*[|!]/, false ) ) {
					state.tokenize = this.inTable.bind( this );
					return this.inTable( stream, state );
				}
			} else {
				if ( stream.match( /^[^'|{[<&~!]+/ ) ) {
					return this.makeStyle( tag, state );
				}
				if ( stream.match( '||' ) || ( isHead && stream.match( '!!' ) ) ) {
					this.isBold = false;
					this.isItalic = false;
					state.tokenize = this.eatTableRow( true, isHead, isCaption );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( isStart && stream.eat( '|' ) ) {
					state.tokenize = this.eatTableRow( false, isHead, isCaption );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
			}
			return this.eatWikiText( tag )( stream, state );
		};
	}

	eatFreeExternalLinkProtocol( stream, state ) {
		stream.match( this.urlProtocols );
		state.tokenize = this.eatFreeExternalLink.bind( this );
		return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
	}

	eatFreeExternalLink( stream, state ) {
		if ( stream.sol() ) {
			// @todo error message
		} else if ( stream.match( /^[^\s\u00a0{[\]<>~).,']*/ ) ) {
			if ( stream.peek() === '~' ) {
				if ( !stream.match( /^~~~+/, false ) ) {
					stream.match( /^~*/ );
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.peek() === '{' ) {
				if ( !stream.match( '{{', false ) ) {
					stream.next();
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.peek() === '\'' ) {
				if ( !stream.match( '\'\'', false ) ) {
					stream.next();
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.match( /^[).,]+(?=[^\s\u00a0{[\]<>~).,])/ ) ) {
				return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
			}
		}
		state.tokenize = state.stack.pop();
		return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
	}

	eatList( stream, state ) {
		// Just consume all nested list and indention syntax when there is more
		const mt = stream.match( /^[*#;:]*/u );
		if ( mt && !this.isNested( state ) && mt[ 0 ].includes( ';' ) ) {
			state.nDt += mt[ 0 ].split( ';' ).length - 1;
		}
		return this.makeLocalStyle( mwModeConfig.tags.list, state );
	}

	/**
	 * @param {string} style
	 * @return {string|Function}
	 * @private
	 */
	eatWikiText( style ) {
		return ( stream, state ) => {
			let ch, tmp, mt, name, isCloseTag, tagname;
			const sol = stream.sol();

			function chain( parser ) {
				state.stack.push( state.tokenize );
				state.tokenize = parser;
				return parser( stream, state );
			}

			if ( sol ) {
				// highlight free external links, see T108448
				if ( !stream.match( '//', false ) && stream.match( this.urlProtocols ) ) {
					state.stack.push( state.tokenize );
					state.tokenize = this.eatFreeExternalLink.bind( this );
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
				}
				ch = stream.next();
				switch ( ch ) {
					case '-':
						if ( stream.match( /^---+/ ) ) {
							return mwModeConfig.tags.hr;
						}
						break;
					case '=':

						tmp = stream.match( /^(={0,5})(.+?(=\1\s*)(.*\S).*?)?)$/ );
						// Title
						if ( tmp ) {
							stream.backUp( tmp[ 2 ].length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatSectionHeader( tmp[ 3 ].length );
							return mwModeConfig.tags.sectionHeader + ' ' +
								/**
								 * Tokens used here include:
								 * - cm-mw-section-1
								 * - cm-mw-section-2
								 * - cm-mw-section-3
								 * - cm-mw-section-4
								 * - cm-mw-section-5
								 * - cm-mw-section-6
								 */
								mwModeConfig.tags[ `sectionHeader${ tmp[ 1 ].length + 1 }` ];
						}
						break;
					case ';':
						stream.backUp( 1 );
					// fall through
					case '*':
					case '#':
						return this.eatList( stream, state );
					case ':':
						// Highlight indented tables :{|, bug T108454
						if ( stream.match( /^:*[\s\u00a0]*(?={\|)/ ) ) {
							state.stack.push( state.tokenize );
							state.tokenize = this.eatStartTable.bind( this );
							return mwModeConfig.tags.indenting;
						}
						return this.eatList( stream, state );
					case ' ':
						// Leading spaces is valid syntax for tables, bug T108454
						if ( stream.match( /^[\s\u00a0]*(?::+[\s\u00a0]*)?{\|/, false ) ) {
							stream.eatSpace();
							if ( stream.match( /^:+/ ) ) { // ::{|
								stream.eatSpace();
								state.stack.push( state.tokenize );
								state.tokenize = this.eatStartTable.bind( this );
								return mwModeConfig.tags.indenting;
							}
							stream.eat( '{' );
						} else {
							return mwModeConfig.tags.skipFormatting;
						}
					// break is not necessary here
					// falls through
					case '{':
						if ( stream.eat( '|' ) ) {
							stream.eatSpace();
							state.stack.push( state.tokenize );
							state.tokenize = this.inTableDefinition.bind( this );
							return mwModeConfig.tags.tableBracket;
						}
				}
			} else {
				ch = stream.next();
			}

			switch ( ch ) {
				case '&':
					return this.makeStyle(
						this.eatHtmlEntity( stream, style ),
						state
					);
				case '\'':
					// skip the irrelevant apostrophes ( >5 or =4 )
					if ( stream.match( /^'*(?=''''')/ ) || stream.match( /^'''(?!')/, false ) ) {
						break;
					}
					if ( stream.match( '\'\'' ) ) { // bold
						if ( !( this.firstSingleLetterWord || stream.match( '\'\'', false ) ) ) {
							this.prepareItalicForCorrection( stream );
						}
						this.isBold = !this.isBold;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophesBold, state );
					} else if ( stream.eat( '\'' ) ) { // italic
						this.isItalic = !this.isItalic;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophesItalic, state );
					}
					break;
				case '[':
					if ( stream.eat( '[' ) ) { // Link Example: [[ Foo | Bar ]]
						stream.eatSpace();
						if ( /[^\]|[]/.test( stream.peek() ) ) {
							state.nLink++;
							state.stack.push( state.tokenize );
							state.tokenize = this.inLink.bind( this );
							return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state );
						}
					} else {
						mt = stream.match( this.urlProtocols );
						if ( mt ) {
							state.nLink++;
							stream.backUp( mt[ 0 ].length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatExternalLinkProtocol( mt[ 0 ].length );
							return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state );
						}
					}
					break;
				case '{':
					// Can't be a variable when it starts with more than 3 brackets (T108450) or
					// a single { followed by a template. E.g. {{{!}} starts a table (T292967).
					if ( stream.match( /^{{(?!{|[^{}]*}}(?!}))/ ) ) {
						stream.eatSpace();
						state.stack.push( state.tokenize );
						state.tokenize = this.inVariable.bind( this );
						return this.makeLocalStyle(
							mwModeConfig.tags.templateVariableBracket,
							state
						);
					} else if ( stream.match( /^{(?!{(?!{))[\s\u00a0]*/ ) ) {
						// Parser function
						if ( stream.peek() === '#' ) {
							state.nExt++;
							state.stack.push( state.tokenize );
							state.tokenize = this.inParserFunctionName.bind( this );
							return this.makeLocalStyle(
								mwModeConfig.tags.parserFunctionBracket,
								state
							);
						}
						// Check for parser function without '#'
						name = stream.match( /^([^}[\]<{|:]+)(.)?/, false );
						if ( name ) {
							const [ , f, delimiter ] = name,
								ff = delimiter === ':' ? f : f.trim(),
								ffLower = ff.toLowerCase(),
								{ config: { functionSynonyms } } = this;
							if (
								( !delimiter || delimiter === ':' || delimiter === '}' ) &&
								(
									Object.prototype.hasOwnProperty.call(
										functionSynonyms[ 0 ], ffLower
									) ||
									Object.prototype.hasOwnProperty.call(
										functionSynonyms[ 1 ], ff
									)
								)
							) {
								state.nExt++;
								state.stack.push( state.tokenize );
								state.tokenize = this.inParserFunctionName.bind( this );
								return this.makeLocalStyle(
									mwModeConfig.tags.parserFunctionBracket,
									state
								);
							}
						}
						// Template
						state.nTemplate++;
						state.stack.push( state.tokenize );
						state.tokenize = this.eatTemplatePageName( false );
						return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state );
					}
					break;
				case '<':
					if ( stream.match( '!--' ) ) { // comment
						return chain( this.eatBlock( mwModeConfig.tags.comment, '-->' ) );
					}
					isCloseTag = !!stream.eat( '/' );
					tagname = stream.match( /^[a-z][^>/\s\u00a0]*/i );
					if ( tagname ) {
						tagname = tagname[ 0 ].toLowerCase();
						if ( tagname in this.config.tags ) {
							// Parser function
							if ( isCloseTag === true ) {
								return mwModeConfig.tags.error;
							}
							stream.backUp( tagname.length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatTagName( tagname.length, isCloseTag, false );
							return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ tagname }`, state );
						}
						if ( tagname in mwModeConfig.permittedHtmlTags ) {
							// Html tag
							if ( isCloseTag === true && tagname !== state.inHtmlTag.pop() ) {
								// Increment position so that the closing '>' gets highlighted red.
								stream.pos++;
								return mwModeConfig.tags.error;
							}
							if (
								isCloseTag === true &&
								tagname in mwModeConfig.implicitlyClosedHtmlTags
							) {
								return mwModeConfig.tags.error;
							}
							stream.backUp( tagname.length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatTagName(
								tagname.length,
								// Opening void tags should also be treated as the closing tag.
								isCloseTag ||
								( tagname in mwModeConfig.implicitlyClosedHtmlTags ),
								true
							);
							return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
						}
						stream.backUp( tagname.length );
					}
					break;
				case '~':
					if ( stream.match( /^~{2,4}/ ) ) {
						return mwModeConfig.tags.signature;
					}
					break;
				// Maybe double underscored Magic Word such as __TOC__
				case '_':
					tmp = 1;
					// Optimize processing of many underscore symbols
					while ( stream.eat( '_' ) ) {
						tmp++;
					}
					// Many underscore symbols
					if ( tmp > 2 ) {
						if ( !stream.eol() ) {
							// Leave last two underscore symbols for processing in next iteration
							stream.backUp( 2 );
						}
						// Optimization: skip regex function for EOL and backup-ed symbols
						return this.makeStyle( style, state );
						// Check on double underscore Magic Word
					} else if ( tmp === 2 ) {
						// The same as the end of function except '_' inside and '__' at the end.
						name = stream.match( /^([^\s\u00a0>}[\]<{'|&:~]+?)__/ );
						if ( name && name[ 0 ] ) {
							if (
								'__' + name[ 0 ].toLowerCase() in this.config.doubleUnderscore[ 0 ] ||
								'__' + name[ 0 ] in this.config.doubleUnderscore[ 1 ]
							) {
								return mwModeConfig.tags.doubleUnderscore;
							}
							if ( !stream.eol() ) {
								// Two underscore symbols at the end can be the
								// beginning of another double underscored Magic Word
								stream.backUp( 2 );
							}
							// Optimization: skip regex for EOL and backup-ed symbols
							return this.makeStyle( style, state );
						}
					}
					break;
				case ':':
					if ( state.nDt > 0 && !this.isNested( state ) ) {
						state.nDt--;
						return mwModeConfig.tags.indenting;
					}
					break;
				default:
					if ( /[\s\u00a0]/.test( ch ) ) {
						stream.eatSpace();
						// highlight free external links, bug T108448
						if ( stream.match( this.urlProtocols, false ) && !stream.match( '//' ) ) {
							state.stack.push( state.tokenize );
							state.tokenize = this.eatFreeExternalLinkProtocol.bind( this );
							return this.makeStyle( style, state );
						}
					}
					break;
			}
			stream.match( /^[^\s\u00a0_>}[\]<{'|&:~=]+/ );
			return this.makeStyle( style, state );
		};
	}

	/**
	 * Remembers position and status for rollbacking.
	 * It is needed for changing from bold to italic with apostrophes before it, if required.
	 *
	 * @see https://phabricator.wikimedia.org/T108455
	 *
	 * @param {StringStream} stream
	 * @private
	 */
	prepareItalicForCorrection( stream ) {
		// See Parser::doQuotes() in MediaWiki Core, it works similarly.
		// this.firstSingleLetterWord has maximum priority
		// this.firstMultiLetterWord has medium priority
		// this.firstSpace has low priority
		const end = stream.pos,
			str = stream.string.slice( 0, end - 3 ),
			x1 = str.slice( -1 ),
			x2 = str.slice( -2, -1 );

		// this.firstSingleLetterWord always is undefined here
		if ( x1 === ' ' ) {
			if ( this.firstMultiLetterWord || this.firstSpace ) {
				return;
			}
			this.firstSpace = end;
		} else if ( x2 === ' ' ) {
			this.firstSingleLetterWord = end;
		} else if ( this.firstMultiLetterWord ) {
			return;
		} else {
			this.firstMultiLetterWord = end;
		}
		// remember bold and italic state for later restoration
		this.wasBold = this.isBold;
		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}
	 * @private
	 */
	get mediawiki() {
		return {
			name: 'mediawiki',

			/**
			 * Initial State for the parser.
			 *
			 * @return {Object}
			 * @private
			 */
			startState: () => ( {
				tokenize: this.eatWikiText( '' ),
				stack: [],
				inHtmlTag: [],
				extName: false,
				extMode: false,
				extState: false,
				nTemplate: 0,
				nLink: 0,
				nExt: 0,
				nDt: 0
			} ),

			/**
			 * Copies the given state.
			 *
			 * @param {Object} state
			 * @return {Object}
			 * @private
			 */
			copyState: ( state ) => ( {
				tokenize: state.tokenize,
				stack: state.stack.concat( [] ),
				inHtmlTag: state.inHtmlTag.concat( [] ),
				extName: state.extName,
				extMode: state.extMode,
				extState: state.extMode !== false && state.extMode.copyState( state.extState ),
				nTemplate: state.nTemplate,
				nLink: state.nLink,
				nExt: state.nExt,
				nDt: state.nDt
			} ),

			/**
			 * Reads one token, advancing the stream past it,
			 * and returning a string indicating the token's style tag.
			 *
			 * @param {StringStream} stream
			 * @param {Object} state
			 * @return {string|null}
			 * @private
			 */
			token: ( stream, state ) => {
				let style, p, t, f,
					readyTokens = [],
					tmpTokens = [];

				if ( this.oldTokens.length > 0 ) {
					// just send saved tokens till they exists
					t = this.oldTokens.shift();
					stream.pos = t.pos;
					state = t.state;
					return t.style;
				}

				if ( stream.sol() ) {
					// reset bold and italic status in every new line
					state.nDt = 0;
					this.isBold = false;
					this.isItalic = false;
					this.firstSingleLetterWord = null;
					this.firstMultiLetterWord = null;
					this.firstSpace = null;
				}

				do {
					// get token style
					style = state.tokenize( stream, state );
					f = this.firstSingleLetterWord || this.firstMultiLetterWord || this.firstSpace;
					if ( f ) {
						// rollback point exists
						if ( f !== p ) {
							// new rollback point
							p = f;
							// it's not first rollback point
							if ( tmpTokens.length > 0 ) {
								// save tokens
								readyTokens = readyTokens.concat( tmpTokens );
								tmpTokens = [];
							}
						}
						// save token
						tmpTokens.push( {
							pos: stream.pos,
							style,
							state: ( state.extMode || this.mediawiki ).copyState( state )
						} );
					} else {
						// rollback point does not exist
						// remember style before possible rollback point
						this.oldStyle = style;
						// just return token style
						return style;
					}
				} while ( !stream.eol() );

				if ( this.isBold && this.isItalic ) {
					// needs to rollback
					// restore status
					this.isItalic = this.wasItalic;
					this.isBold = this.wasBold;
					this.firstSingleLetterWord = null;
					this.firstMultiLetterWord = null;
					this.firstSpace = null;
					if ( readyTokens.length > 0 ) {
						// it contains tickets before the point of rollback
						// add one apostrophe, next token will be italic (two apostrophes)
						readyTokens[ readyTokens.length - 1 ].pos++;
						// for sending tokens till the point of rollback
						this.oldTokens = readyTokens;
					} else {
						// there are no tickets before the point of rollback
						stream.pos = tmpTokens[ 0 ].pos - 2; // eat( '\'')
						// send saved Style
						return this.oldStyle;
					}
				} else {
					// do not need to rollback
					// send all saved tokens
					this.oldTokens = readyTokens.concat( tmpTokens );
				}
				// return first saved token
				t = this.oldTokens.shift();
				stream.pos = t.pos;
				state = t.state;
				return t.style;
			},

			/**
			 * @param {Object} state
			 * @private
			 */
			blankLine: ( state ) => {
				if ( state.extMode && state.extMode.blankLine ) {
					state.extMode.blankLine( state.extState );
				}
			},

			/**
			 * Extra tokens to use in this parser.
			 *
			 * @see CodeMirrorModeMediaWikiConfig.defaultTokenTable
			 * @return {Object}
			 * @private
			 */
			tokenTable: this.tokenTable,

			/**
			 * @see https://codemirror.net/docs/ref/#language.StreamParser.languageData
			 * @return {Object}
			 * @private
			 */
			languageData: {
				// TODO: Rewrite the comment command using jQuery.textSelection
				commentTokens: { block: { open: '' } },
				autocomplete: this.completionSource
			}
		};
	}
}

let handler;

/**
 * Gets a LanguageSupport instance for the MediaWiki mode.
 *
 * @member CodeMirrorModeMediaWiki
 * @method
 * @param {Object} [config] Configuration options for the MediaWiki mode.
 * @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
 */
const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) => {
	mwConfig = mwConfig || mw.config.get( 'extCodeMirrorConfig' );
	const mode = new CodeMirrorModeMediaWiki( mwConfig );
	const parser = mode.mediawiki;
	const lang = StreamLanguage.define( parser );
	const langExtension = [ syntaxHighlighting(
		HighlightStyle.define(
			mwModeConfig.getTagStyles( parser )
		)
	) ];

	// Register MW-specific Extensions into CodeMirror preferences. Whether they are enabled
	// or not is determined by the user's preferences and wiki configuration.
	if ( handler ) {
		mw.hook( 'ext.CodeMirror.ready' ).remove( handler );
	}
	handler = ( $textarea, cm ) => {
		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 );
		}
	};
	mw.hook( 'ext.CodeMirror.ready' ).add( handler );

	return new LanguageSupport( lang, langExtension );
};

module.exports = mediaWikiLang;