// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: https://codemirror.net/5/LICENSE
// This QFQ language mode was written by Elias Villiger

// See CODEMIRROR.md for some project details

(function(mod) {
    if (typeof exports == "object" && typeof module == "object") // CommonJS
        mod(require("codemirror"));
    else if (typeof define == "function" && define.amd) // AMD
        define(["codemirror"], mod);
    else // Plain browser env
        mod(CodeMirror);
})(function(CodeMirror) {
    "use strict";

    CodeMirror.defineMode("qfq", function(config) {
        var keywords = { // order inside each keyword type affects hinting order -
            "qfq-base": "form r dbIndex debugShowBodyText sqlLog sqlLogMode render performanceReport",
            "qfq-level": "fbeg fend fsep fskipwrap shead stail head tail lbeg lend libeg liend rbeg rbgd " +
                "rend renr rsep sql twig althead altsql content fireIf fireSubIf function",
            "qfq-store": "A B C D E F L M P R S T U V W Y 0",
            "qfq-sanitize": "alnumx digit numerical allbut all",
            "qfq-escape": "c C d D E f h H l L m p P s S t T w X '' -",
            "qfq-store-variables":
            /* C Store: */ "Authorization X-Api-Key HTTP_HOST REMOTE_ADDR SERVER_ADDR SERVER_NAME SERVER_SOFTWARESERVER_PROTOCOL REQUEST_METHOD REQUEST_TIME REQUEST_TIME_FLOAT QUERY_STRING DOCUMENT_ROOT HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING HTTP_ACCEPT_LANGUAGE HTTP_CONNECTION HTTP_HOST HTTP_REFERER HTTP_USER_AGENT HTTPS REMOTE_ADDR REMOTE_HOST REMOTE_PORT REMOTE_USER REDIRECT_REMOTE_USER SCRIPT_FILENAME SERVER_ADMIN SERVER_PORT SERVER_SIGNATURE PATH_TRANSLATED SCRIPT_NAME REQUEST_URI PHP_AUTH_DIGEST PHP_AUTH_USER PHP_AUTH_PW AUTH_TYPE PATH_INFO ORIG_PATH_INFO " +
                /* T Store: */ "form pageSlug pageId pageAbstract pageAlias pageDescription pageKeywords pageLanguage pageNavTitle pageTitle pageType ttcontentSubheader ttcontentUid feUser feUserGroup feUserEmail feUserUid beUserLoggedIn beUser beUserUid beUserEmail t3version oidcbeUid oidcFeUid oidcBeProvider oidcFeProvider oidcBeProviderId oidcFeProviderId oidcBeResourceId oidcFeResourceId oidcBeSub oidcFeSub oidcBeEmail oidcFeEmail oidcBeEmailVerified oidcFeEmailVerified oidcBeName oidcFeName oidcBeGivenName oidcFeGivenName oidcBeFamilyName oidcFeFamilyName " +
                /* V Store: */ "random slaveId allRequiredGiven filename filenameOnly filenameBase filenameExt fileDestination fileSize mimeType " +
                /* Y Store: */ "dbNameData dbNameQfq dbNameT3 sitePath extPath qfqProjectPath flagProduction render baseUrl dateFormat editInlineReports reportAsFileAutoExport websocketPort websocketUrl cmdInkscape cmdConvert cmdPdf2svg cmdPdftocairo cmdWkhtmltopdf cmdQpdf cmdGs cmdPdfunite cmdImg2pdf cmdHeifConvert cmdPdf2ps cmdPs2pdf throwExceptionGeneralError formSubmitLogMode redirectAllMailTo sqlLogMode sqlLogModeAutoCron sqlLog qfqLog mailLog showDebugInfo reportMiniPhpVersion init update indexData indexQfq sessionTimeoutSeconds escapeTypeDefault securityVarsHoneypot securityAttackDelay securityShowMessage securityGetMaxLength securityFailedAuthDelay encryptionMethod protectedFolderCheck recordLockTimeoutSeconds enterAsSubmit editFormPage dateTimePickerType formDataPatternError formDataRequiredError formDataMatchError formDataError showIdInFormTitle cssClassColumnId clearMe rememberLastPill doNotLogColumn labelAlign cssClassQfqContainer cssClassQfqForm cssClassQfqFormPill cssClassQfqFormBody formBsColumns formBsLabelColumns formBsInputColumns formBsNoteColumns extraButtonInfoInline extraButtonInfoBelow extraButtonInfoPosition extraButtonInfoClass fieldsetClass formLanguageAId formLanguageBId formLanguageCId formLanguageDId formLanguageALabel formLanguageBLabel formLanguageCLabel formLanguageDLabel saveButtonText saveButtonTooltip saveButtonClass buttonOnChangeClass saveButtonGlyphIcon closeButtonText closeButtonTooltip closeButtonClass closeButtonGlyphIcon deleteButtonText deleteButtonTooltip deleteButtonGlyphIcon newButtonText newButtonTooltip newButtonClass newButtonGlyphIcon fillStoreSystemBySqlRow fillStoreSystemBySqlRowErrorMsg fillStoreSystemBySql1 fillStoreSystemBySql2 fillStoreSystemBySql3 fillStoreSystemBySqlErrorMsg1 fillStoreSystemBySqlErrorMsg2 fillStoreSystemBySqlErrorMsg3 cacheDirSecure cachePurgeFilesOlderDays maxFileSize uploadType imageUploadDir thumbnailDirSecure thumbnailDirPublic forceSmtpSender sendEMailOptions cmdWget",

            "sql-reserved": "as accessible add all alter analyze and asc asensitive between by bigint before binary blob both call cascade case change char character check collate column condition constraint continue convert create cross cube cume_dist current_date current_time current_timestamp current_user cursor database databases day_hour day_microsecond day_minute day_second dec decimal declare default delayed delete dense_rank desc describe deterministic distinct distinctrow div double drop dual each else elseif empty enclosed escaped except exists exit explain from fetch first_value float float4 float8 for force foreign fulltext function group generated get grant grouping groups having high_priority hour_microsecond hour_minute hour_second inner in if ignore index infile inout insensitive insert int int1 int2 int3 int4 int8 integer intersect interval into io_after_gtids io_before_gtids is iterate join json_table key keys kill left like limit lag last_value lateral lead leading leave linear lines load localtime localtimestamp lock long longblob longtext loop low_priority master_bind master_ssl_verify_server_cert match maxvalue mediumblob mediumint mediumtext middleint minute_microsecond minute_second mod modifies natural no_write_to_binlog not nth_value ntile numeric order outer of on optimize optimizer_costs option optionally or out outfile over partition percent_rank precision primary procedure purge range rank read read_write reads real recursive references regexp release rename repeat replace require resignal restrict return revoke right rlike row row_number rows select schema schemas second_microsecond sensitive separator set show signal smallint spatial specific sql sql_big_result sql_calc_found_rows sql_small_result sqlexception sqlstate sqlwarning ssl starting stored straight_join system system table terminated then tinyblob tinyint tinytext to trailing trigger undo union unique unlock unsigned update usage use using utc_date utc_time utc_timestamp values varbinary varchar varcharacter varying virtual where when while window with write xor year_month zerofill",
            "sql-keyword": "account action active admin after against aggregate algorithm always any array ascii at attribute authentication auto_increment autoextend_size avg avg_row_length backup begin binlog bit block bool boolean btree buckets bulk byte cache cascaded catalog_name chain challenge_response changed channel charset checksum cipher class_origin client clone close coalesce code collation column_format column_name columns comment commit committed compact completion component compressed compression concurrent connection consistent constraint_catalog constraint_name constraint_schema contains context cpu current cursor_name data datafile date datetime day deallocate default_auth definer definition delay_key_write description diagnostics directory disable discard disk do dumpfile duplicate dynamic enable encryption end ends enforced engine engine_attribute engines enum error errors escape event events every exchange exclude execute expansion expire export extended extent_size factor failed_login_attempts fast faults fields file file_block_size filter finish first fixed flush following follows format found full general generate geomcollection geometry geometrycollection get_format get_master_public_key get_source_public_key global grants group_replication gtid_only handler hash help histogram history host hosts hour identified ignore_server_ids import inactive indexes initial initial_size initiate insert_method install instance invisible invoker io io_thread ipc isolation issuer json json_value key_block_size keyring language last leaves less level linestring list local locked locks logfile logs master master_auto_position master_compression_algorithms master_connect_retry master_delay master_heartbeat_period master_host master_log_file master_log_pos master_password master_port master_public_key_path master_retry_count master_ssl master_ssl_ca master_ssl_capath master_ssl_cert master_ssl_cipher master_ssl_crl master_ssl_crlpath master_ssl_key master_tls_ciphersuites master_tls_version master_user master_zstd_compression_level max_connections_per_hour max_queries_per_hour max_rows max_size max_updates_per_hour max_user_connections medium member memory merge message_text microsecond migrate min_rows minute mode modify month multilinestring multipoint multipolygon mutex mysql_errno name names national nchar ndb ndbcluster nested network_namespace never new next no no_wait nodegroup none nowait nulls number nvarchar off offset oj old one only open optional options ordinality organization others owner pack_keys page parser partial partitioning partitions password password_lock_time path persist persist_only phase plugin plugin_dir plugins point polygon port precedes preceding prepare preserve prev privilege_checks_user privileges process processlist profile profiles proxy quarter query quick random read_only rebuild recover redo_buffer_size redundant reference registration relay relay_log_file relay_log_pos relay_thread relaylog reload remove reorganize repair repeatable replica replicas replicate_do_db replicate_do_table replicate_ignore_db replicate_ignore_table replicate_rewrite_db replicate_wild_do_table replicate_wild_ignore_table replication require_row_format reset resource respect restart restore resume retain returned_sqlstate returning returns reuse reverse role rollback rollup rotate routine row_count row_format rtree savepoint schedule schema_name second secondary secondary_engine secondary_engine_attribute secondary_load secondary_unload security serial serializable server session share shutdown signed simple skip slave slow snapshot socket some soname sounds source source_auto_position source_bind source_compression_algorithms source_connect_retry source_delay source_heartbeat_period source_host source_log_file source_log_pos source_password source_port source_public_key_path source_retry_count source_ssl source_ssl_ca source_ssl_capath source_ssl_cert source_ssl_cipher source_ssl_crl source_ssl_crlpath source_ssl_key source_ssl_verify_server_cert source_tls_ciphersuites source_tls_version source_user source_zstd_compression_level sql_after_gtids sql_after_mts_gaps sql_before_gtids sql_buffer_result sql_no_cache sql_thread sql_tsi_day sql_tsi_hour sql_tsi_minute sql_tsi_month sql_tsi_quarter sql_tsi_second sql_tsi_week sql_tsi_year srid stacked start starts stats_auto_recalc stats_persistent stats_sample_pages status stop storage stream string subclass_origin subject subpartition subpartitions super suspend swaps switches table_checksum table_name tables tablespace temporary temptable text than thread_priority ties time timestamp timestampadd timestampdiff tls transaction triggers truncate type types unbounded uncommitted undefined undo_buffer_size undofile unicode uninstall unregister until upgrade url use_frm user user_resources validation value variables vcpu view visible wait warnings week weight_string without work wrapper x509 xa xid xml year zone",
            "sql-function": /*functions are followed by parenthesis*/ "abs acos adddate addtime aes_decrypt aes_encrypt any_value ascii asin atan avg benchmark bin bin_to_uuid bit_and bit_count bit_length bit_or bit_xor can_access_column can_access_database can_access_table can_access_user can_access_view cast ceil ceiling char char_length character_length charset coalesce coercibility collation compress concat concat_ws connection_id conv convert convert_tz cos cot count cume_dist curdate current_date current_role current_time current_timestamp current_user curtime database date date_add date_format date_sub datediff day dayname dayofmonth dayofweek dayofyear default degrees dense_rank elt exp export_set extract field find_in_set first_value floor format format_bytes format_pico_time found_rows from_days from_unixtime get_dd_column_privileges get_dd_create_options get_dd_index_sub_part_length get_format get_lock greatest group_concat grouping gtid_subset gtid_subtract hex hour icu_version if ifnull in inet_aton inet_ntoa insert instr internal_auto_increment internal_avg_row_length internal_check_time internal_checksum internal_data_free internal_data_length internal_dd_char_length internal_get_comment_or_error internal_get_enabled_role_json internal_get_hostname internal_get_username internal_get_view_warning_or_error internal_index_column_cardinality internal_index_length internal_is_enabled_role internal_is_mandatory_role internal_keys_disabled internal_max_data_length internal_table_rows internal_update_time interval is_free_lock is_used_lock is_uuid isnull json_array json_array_append json_array_insert json_arrayagg json_contains json_contains_path json_depth json_extract json_insert json_keys json_length json_merge json_merge_patch json_merge_preserve json_object json_objectagg json_overlaps json_pretty json_quote json_remove json_replace json_schema_valid json_schema_validation_report json_search json_set json_storage_free json_storage_size json_table json_type json_unquote json_valid json_value lag last_insert_id last_value lcase lead least left length ln load_file localtime localtimestamp locate log lower lpad ltrim make_set makedate maketime master_pos_wait match max microsecond mid min minute mod month monthname name_const now nth_value ntile nullif oct octet_length ord percent_rank period_add period_diff pi position pow power ps_current_thread_id ps_thread_id quarter quote radians rand random_bytes rank regexp_instr regexp_like regexp_replace regexp_substr release_all_locks release_lock repeat replace reverse right roles_graphml round row_count row_number rpad rtrim schema sec_to_time second session_user sha sign sin sleep soundex source_pos_wait space sqrt st_srid st_x st_y statement_digest statement_digest_text std stddev stddev_pop stddev_samp str_to_date strcmp subdate substr substring substring_index subtime sum sysdate system_user tan time time_format time_to_sec timediff timestamp timestampadd timestampdiff to_days to_seconds trim truncate ucase uncompress uncompressed_length unhex unix_timestamp upper user utc_date utc_time utc_timestamp uuid uuid_short uuid_to_bin validate_password_strength values var_pop var_samp variance version wait_for_executed_gtid_set wait_until_sql_thread_after_gtids week weekday weekofyear weight_string year yearweek last_day " +
                "qbar qcc qnl2br qnbsp qleft qright qmore qifempty qdate_format qslugify qent_squote qent_dquote qesc_squote qesc_dquote qmanr strip_tags qent_encode qent_decode",
            "sql-atom" : "false true null unknown",
            "html-tag": "div table tr th td button data head legend span script embed object template picture svg dl dd dialog select footer optgroup hgroup header a label li textarea p param i code wbr ruby meter search section figcaption blockquote q br time caption dt ins hr title var summary details abbr noscript area article rt form img map iframe input option ol menu ul b address aside audio video em h1 h2 h3 h4 h5 h6 strong kbd mark meta source nav pre samp small u style sub sup del s track body link output html cite rp fieldset tbody tfoot thead bdi bdo progress colgroup datalist dfn col figure base main canvas",
            "html-attr": "download target href rel hreflang media type coords shape alt onerror src onabort oncanplay autoplay controls loop oncanplaythrough ondurationchange onemptied onended onloadeddata onloadedmetadata onloadstart onpause onplay onplaying onprogress onratechange onseeked onseeking onstalled onsuspend ontimeupdate onvolumechange onwaiting preload cite onafterprint onbeforeprint onbeforeunload onhashchange onoffline ononline onpagehide onpageshow onpopstate onresize onstorage onunload onload name form disabled formaction popovertarget popovertargetaction value autofocus height width span datetime ontoggle open accept-charset action enctype method novalidate onreset onsubmit autocomplete sandbox srcdoc ismap sizes usemap srcset accept checked list onsearch pattern step min max multiple size required dirname maxlength placeholder readonly for content http-equiv charset high low optimum data reversed start selected async defer colspan headers rowspan cols rows wrap scope default kind oncuechange srclang label poster muted onblur onchange onclick oncontextmenu oncopy oncut ondblclick ondrag ondragend ondragenter ondragleave ondragover ondragstart ondrop onfocus oninput oninvalid onkeydown onkeypress onkeyup onmousedown onmousemove onmouseout onmouseover onmouseup onmousewheel onpaste onscroll onselect onwheel accesskey class contenteditable dir draggable enterkeyhint hidden id inert inputmode lang popover spellcheck style tabindex title translate",
            "qfq-form-vars": "typeAheadGlueUpdate fileNote fileNoteTarget toggleInput formModeGlobal maxImageDimension typeAheadPedantic btnFooterLeftOrder onloadedmetadata datetime sandbox ldapServer new onfocus editor-relative_urls typeAheadInitialSuggestion inert high onpopstate onsubmit minWidth dbIndex subrecordAppendForm htmlBefore onhashchange reversed codemirror-keywords-qfq-base placeholder ondragover onmouseover lang typeAheadMinLength multiFormCellWrap size value submitButtonClass wrapNote typeAheadGlueDelete closeButtonGlyphIcon oncuechange buttonClass retype data-error oninvalid saveFormJsonName contenteditable importType tgRemoveClass draggable sendMailXId sqlInsert class deleteButtonGlyphIcon requiredPosition sendMailSubjectHtmlEntity typeAheadLdapSearchPerToken fillStoreVar annotateUserName sendMailMode sendMailCc editor-plugins shape fileTrash checkBoxState autoOrient defaultValue editor-forced_root_block newButtonGlyphIcon tgAddClass extraButtonInfoClass onpause popover popovertargetaction btnFooterRightOrder deleteButtonClass characterCountWrap autoOrientCmd username slaveId onabort onended onkeydown codemirror-autoCloseBrackets btnPreviousNextWrap multiple saveButtonTooltip editor-statusbar submitButtonGlyphIcon sendMailXId3 autoplay height subrecordColumnTitleDelete list importSetReadDataOnly alert srcset typeAheadLdapSearchPrefetch tgRemoveText xId subrecordTableClass sendMailGrId wrap ontimeupdate onmouseout showIdInFormTitle codemirror-tabSize spellcheck fileDestinationSplit readonly multiSql sendMailSubject qfqLog kiteKey inputmode onsearch sendMailReplyTo importMode closeButtonTooltip accept popovertarget onselect maxVisiblePill onseeking sqlHonorFormElements btnFooterWrap textareaResize onload data-match-error sizes min importToTable keepOriginal importRegion pId oninput btnTopRight closeButtonText subrecordTableAttribute ldapAttributes title onchange annotateUserAvatar classBody maxlength optimum usemap newButtonClass typeAheadLdapValuePrintf oncontextmenu extraButtonPassword extraDeleteForm showHistory fileTrashText multiFormAddRow dndTable outputWidth pattern onunload checkBoxMode fileUploadPath http-equiv low inputType sendMailTo btnTopRightOrder oncopy btnPreviousNextSql preload wrapInput ondragstart outputHeight typeAheadTagInsert detail submitButtonText classTitle doNotLogColumn timeIsOptional btnFooterLeftWrap orderInterval dragAndDropOrderSql onprogress kind trim content importListSheetNames enterAsSubmit editor-extended_valid_elements typeAheadLdap data chmodFile span ismap subrecordEmptyText sqlAfter sqlValidate expectRecords messageFail extraButtonLock ldapUseBindCredentials fileSplitOptions sendMailBodyHtmlEntity fileSplit onstorage ldapSearch enctype charset media required onbeforeunload outputQualityJpeg editor-content_css target recentLog onmousedown fileUnzip dir onvolumechange classPill onplaying ondblclick btnTopLeft translateIdColumn maxFileSize sendMailAttachment saveButtonClass importFromColumns previewHtmlId autoOrientMimeType bsColumns onwheel decimalFormat extraButtonInfo for closeButtonClass onreset headers codemirror-lineWrapping width dirname annotateUserUid sqlBefore subrecordAppendExtraDeleteForm mode orderColumn htmlAfter importNamedSheetsOnly translate type uploadText rowspan rows tgCurentIndex action typeAheadLimit btnTopLeftWrap async modeSql data-pattern-error formSubmitLogMode name label sqlDelete form onemptied typeAheadSqlPrefetch method href oncanplay codemirror-styleActiveLine onclick ondragenter emptyItemAtStart sendMailXId2 style ontoggle sql1 outputFormat chmodDir multiMode ondrag acceptZeroAsRequired data-required-error sendMailBcc loop coords btnPreviousNextLoop onafterprint onkeypress btnTopRightWrap sendMailFrom readOnly clearMe datalist buttonOnChangeClass saveButtonText ononline processRow rememberLastPill btnCustom selected onscroll onpagehide dataReference newButtonText cols darkenMargin cite onbeforeprint saveFormJson onwaiting poster itemList activateFirstRequiredTab btnTopWrap highlight onplay wrapRow saveButtonGlyphIcon tableNameSplit hidden open dateTimePickerType page showSeconds defaultPenColor processReadOnly typeAheadGlueInsert fieldsetCss enterkeyhint id alt novalidate typeAheadLdapSearch onseeked download accesskey hreflang accept-charset fillStoreLdap showHistoryFeUserSet btnTopLeftOrder onoffline tabindex onmousemove onpageshow multiFormWrap codemirror-matchBrackets sendMailFlagAutoSubmit retypeLabel textSource controls tgClass onerror autofocus default imageSource importToColumns completeMessage Publication formaction pIdCreator scope showHistoryTitle targetTable onratechange emptyMeansNull showZero tgAddText onloadeddata fileNameToLower defer rel max onmouseup subrecordAppendSql muted recordSourceTable fileType autocomplete typeAheadSql aiAssist aiPrompt aiContext aiModel aiTargetLanguage aiRagSource aiRagProject aiRagAcl aiRagK aiChatAllowGeneralKnowledge aiThinking aiChatClass hideZero onmousewheel checked message step subrecordColumnTitleEdit oncut onstalled onsuspend emptyItemAtEnd fileReplace onblur start cIdTopic onkeyup retypeNote subrecordAppendClass multiFormDeleteRow newButtonTooltip src ondrop onresize unchecked subrecordEmpty sessionTimeoutSeconds colspan ondurationchange fileButtonText typeAheadLdapIdPrintf fileDestination fileKey onloadstart htmlAllow requiredList dateFormatSave dateFormat ondragleave srclang sqlUpdate deleteButtonText deleteButtonTooltip editorType codemirror-mode indicateRequired editor-toolbar srcdoc downloadButton recordDestinationTable ondragend ldapBaseDn saveButtonActive annotateType onpaste submitButtonToolTip disabled typeAheadTag fieldsetClass typeAheadTagDelimiter wrapLabel codemirror-lineNumbers uploadType btnFooterRightWrap ldapTimeLimit extraButtonInfoMinWidth multiMsgNoRecord copyRule copyRuleImport icjTemplate multiFormTableClass thClass column",
            /*deleted "data-*" */
            "db-table": "",
            "db-column": "",

            /*config types*/
            "qfq-sql-var-starter": "select show update insert delete",
            "sql-base-indent": "from join left inner outer where group having order limit union"
        }

        const BRACKETABLE_LEVEL_TOKENS = ["lbeg", "lend", "libeg", "liend"];

        // turn keyword strings into arrays for easier checking
        for (var kwType in keywords) {
            if (config["keywords-" + kwType]) {
                keywords[kwType] += " " + config["keywords-" + kwType];
            }
            // robust split: any whitespace, remove empties/trailing spaces
            keywords[kwType] = keywords[kwType].trim().split(/\s+/).filter(Boolean);
        }

        // Process tableColumns config option, if given
        var dbSchema = {}, hasDbSchema = false;
        if (config.tableColumns) {
            hasDbSchema = true;
            var tableColumns = config.tableColumns.split(",");
            for (var tc of tableColumns) {
                var tcSplit = tc.split(".");
                var t = tcSplit[0], c = tcSplit[1];
                dbSchema[t] = dbSchema[t] || [];
                pushNew(dbSchema[t], c);
                pushNew(keywords["db-table"], t);
                pushNew(keywords["db-column"], c);
            }
        }

        CodeMirror.commands.capitalizeSql = function(cm) {
            var cur = cm.getCursor();
            var curToken = cm.getTokenAt({ line: cur.line, ch: cur.ch - 1 });
            var lineTokens = curToken.state.lineTokens;
            var curLineToken = lineTokens[lineTokens.length-1];
            if (curLineToken &&
                curLineToken.type.startsWith("sql-") && curLineToken.content.toUpperCase() !== "SQL") {
                cm.replaceRange(curToken.string.toUpperCase(),
                    CodeMirror.Pos(cur.line, curToken.start),
                    CodeMirror.Pos(cur.line, cur.ch - 1) );
            }
        };

        CodeMirror.commands.commentToggle = function(cm) {
            var startLine = cm.getCursor().line, endLine = startLine;
            if (cm.somethingSelected()) {
                var sel = cm.listSelections()[0]; // doesn't handle multiple selections
                startLine = Math.min(sel.head.line, sel.anchor.line);
                endLine = Math.max(sel.head.line, sel.anchor.line);
            }

            var uncomment = false; // either uncomment or comment
            if (cm.getLine(startLine).trim().startsWith("#")) uncomment = true;
            for (var lineNum = startLine; lineNum <= endLine; ++lineNum) {
                var line = cm.getLine(lineNum), replaceLine = '';
                if (uncomment) {
                    replaceLine = line.replace(/(?<=^\s*)#/, ''); // remove leading #
                } else {
                    replaceLine = '#' + line;
                }
                cm.replaceRange(replaceLine, {line: lineNum, ch: 0}, {line: lineNum, ch: line.length});
            }
        }

        var indentUnit = config.indentUnit;
        var openingSeparators = ["<", "(", "[", "{"];
        var closingSeparators = [">", ")", "]", "}"];

        // RegExp for level keyword, e.g. "10.sql = "
        var regExp = {
            qfqLevelKeyword: function(insideLevel = true) {
                const lvl = keywords["qfq-level"].map(rxEscape).join('|');
                return '^\\s*(\\w+\\.)' + (insideLevel ? '*' : '+') +
                    '(?<kw>(' + lvl + ')(?:\\[\\d*\\])?)\\s*=\\s*';
            },
            qfqBaseKeyword: '^\\s*(?:(' + keywords["qfq-base"].join('|') + '))\\s*=\\s*',
            qfqOpeningLevel: function(sep) {
                var sepStart = sep ? sep.start : openingSeparators.join('|\\');
                var sepEnd = sep ? sep.end : closingSeparators.join('|\\');
                return '^\\s*(\\w+(\\.\\w+)*)?\\s*(\\' + sepStart + ')(\\' + sepEnd + ')?' + '$';
            },
            qfqClosingLevel: function(sep) {
                var sepEnd = sep ? sep.end : closingSeparators.join('|\\');
                return '^\\s*(\\' + sepEnd + ')$';
            },
            qfqSqlVariable: function(checkedOpening = true) {
                return (checkedOpening ? '^' : '\\{\\{') +
                    '\\s*!?(?:(' + keywords["qfq-sql-var-starter"].join('|') + '))\\b'
            },
            sqlBaseIndentKeyword: '^\\s*(?:(' + keywords["sql-base-indent"].join('|') + '))\\s'
        }

        //// Tokenize Helper Functions ////
        function makeLineToken(type, content) {
            return { type, content }; // ES6 object literal
        }

        // push an item to an array, but only if it doesn't exist yet
        function pushNew(arr, item) {
            if (!arr.includes(item))
                arr.push(item);
        }

        function qfqLevelDefinesSql(word) {
            return (word == "sql" || word == "altsql");
        }

        // Checks if the given word is an sql keyword
        // If so, returns its lineToken, otherwise returns false
        function isSqlKeyword(word, nextChar) {
            word = word.toLowerCase();
            if (keywords["sql-function"].includes(word) && nextChar == "(")
                return makeLineToken("sql-function", word);
            if (keywords["sql-reserved"].includes(word))
                return makeLineToken("sql-reserved", word);
            if (keywords["sql-keyword"].includes(word))
                return makeLineToken("sql-keyword", word);
            if (keywords["sql-atom"].includes(word))
                return makeLineToken("sql-atom", word);
        }

        // tokenBase is the default tokenizer
        // It advances the stream one or more characters at a time and evaluates them.
        // For more complicated (esp. multiline) handling it changes the tokenizing "mode", e.g. to tokenString
        function tokenBase(stream, state) {
            var ch = stream.next(), lts = state.lineTokens, isFirst = lts.length == 0;
            var previousToken = lts[lts.length - 1] || makeLineToken();
            var previous2Token = lts[lts.length - 2] || makeLineToken();
            var sqlMode = state.sql.isActive;

            if (ch == "#" && isFirst) { // QFQ comment
                stream.skipToEnd();
                if (stream.lineOracle.line == 0) { // *last* character of *first* line can define separator
                    stream.backUp(1);
                    setMaybeSeparator(state.sep, stream.next());
                }
                return makeLineToken("qfq-comment");
            } else if (sqlMode && (ch == "#" || ch == "-" && stream.match("-"))) { // sql comment
                stream.skipToEnd();
                return makeLineToken("sql-comment");
            } else if (ch == ".") {
                return makeLineToken(ch);
            } else if (/[\w-]/.test(ch)) { // Word (or number)
                stream.eatWhile(/[\w-]/);

                // read optional bracket suffix: [digits] or []
                let word = stream.current();
                if (stream.peek() === '[') {
                    const startPos = stream.pos;
                    stream.next(); // '['
                    let digits = '';
                    while (!stream.eol() && /[0-9]/.test(stream.peek())) digits += stream.next();
                    if (stream.peek() === ']') {
                        stream.next(); // ']'
                        word += '[' + digits + ']';     // keep exact content, NOT always "[]"
                    } else {
                        // not a well-formed [..] -> rewind
                        stream.pos = startPos;
                    }
                }

                const baseWord = word.replace(/\[\d*\]$/, ''); // normalize for keyword lookup

                let lineToken;

                if (keywords["qfq-level"].includes(baseWord) && // qfq level word (e.g. lbeg[2] =)
                    (state.foundQfqLevelName || (isFirst && state.lvlNesting > 0)) &&
                    stream.match(/^\s*=\s*/)) {
                    resetSql(state, stream, qfqLevelDefinesSql(baseWord), true);
                    state.foundQfqLevelName = false;
                    state.qfqLevelIndentColumn = stream.pos;
                    return makeLineToken("qfq-level", word); // keep original (with [n]) for display
                } else if (state.foundQfqLevelName) {
                    return makeLineToken("qfq-level-name");
                } else if (!state.foundQfqLevelName && isFirst && (function(){
                    const line = stream.string;

                    // 1) Dotted keyword assignment, e.g. "10.10.sql =" or "alias.x.sql ="
                    const lvlKw = keywords["qfq-level"].map(rxEscape).join('|');
                    const dottedKwAssign = new RegExp(
                        '^\\s*(?:[\\w-]+\\.)+(?:' + lvlKw + ')(?:\\[\\d*\\])?\\s*=\\s*'
                    );

                    // 2) Alias/number + opening brace, e.g. "customNameIndex {" or "10 {"
                    const brace = rxEscape(state.sep.start);
                    const aliasOrNumberBeforeBrace = new RegExp(
                        '^\\s*[\\w-]+(?:\\.[\\w-]+)*\\s*' + brace + '\\s*$'
                    );

                    return dottedKwAssign.test(line) || aliasOrNumberBeforeBrace.test(line);
                })()) {
                    state.foundQfqLevelName = true;
                    return makeLineToken("qfq-level-name");
                } else if (/^-?\d+$/.test(word)) {
                    return makeLineToken("number");
                } else if (keywords["qfq-base"].includes(baseWord) && isFirst &&
                    stream.match(/^\s*=\s*/) && state.lvlNesting == 0) {
                    resetSql(state, stream, false, true);
                    return makeLineToken("qfq-base", baseWord);
                }
                if (sqlMode) { // try to find SQL table vs column names
                    // Explanation of the various arrays:
                    // SELECT <not evaluated> AS <columnAlias>
                    // FROM <tableParsed> AS <tableAlias>
                    // WHERE <tableParsed>.<columnParsed>
                    if (keywords["db-table"].includes(word) || // we know there is such a table name
                        state.tablesParsed.includes(word) || state.sql.tableAliases.hasOwnProperty(word)) {
                        return makeLineToken("db-table", word);
                    } else if (previousToken.content == "as" && state.sql.passedFROM) { // table alias
                        if (previous2Token.type == "db-table")
                            state.sql.tableAliases[word] = previous2Token.content;
                        return makeLineToken("db-table", word);
                    } else if (["from", "join"].includes(previousToken.content) || stream.peek() == ".") { // table parsed
                        if (!hasDbSchema) pushNew(state.tablesParsed, word);
                        return makeLineToken("db-table", word);
                    } else if (keywords["db-column"].includes(word) || // we know there is such a column name
                        state.columnsParsed.includes(word) || state.sql.columnAliases.includes(word)) {
                        return makeLineToken("db-column", word);
                    } else if (previousToken.content == "as" && !state.sql.passedFROM) { // column alias
                        pushNew(state.sql.columnAliases, word);
                        pushNew(state.qfqVarSuggestions, word);
                        if (word.startsWith("_")) pushNew(state.qfqVarSuggestions, word.slice(1));
                        return makeLineToken("db-column", word);
                    } else if (previousToken.type == "." && !hasDbSchema) { // column parsed
                        pushNew(state.columnsParsed, word);
                        return makeLineToken("db-column", word);
                    } else if (previousToken.type != "." && (lineToken = isSqlKeyword(word, stream.peek()))) {
                        if (word.toLowerCase() == "from") state.sql.passedFROM = true;
                        if (word.toLowerCase() == "select") state.sql.passedFROM = false; // also handles nested SELECT statements
                        return lineToken;
                    }
                } else { // not sql mode
                    if (keywords["html-tag"].includes(word.toLowerCase()) &&
                        !isFirst && previousToken.type.charAt(0) == "<") {
                        return makeLineToken("html-tag", word)
                    } else if (keywords["html-attr"].includes(word.toLowerCase())) {
                        return makeLineToken("html-attr", word);
                    } else if (keywords["qfq-form-vars"].includes(word)) {
                        return makeLineToken("qfq-form-vars", word)
                    }
                }
                return makeLineToken("word", word);
            } else if (ch == "{" && stream.eat("{")) { // QFQ variable
                stream.backUp(2);
                pushTokenizer(state, stream, tokenQfqVariable); // switch tokenizer
                state.alreadyPushedTokenizer = true;
                return state.tokenize(stream, state);
            } else if (ch == "}" && stream.eat("}")) { // QFQ variable closing
                if (state.qfqVarNesting === 0) {
                    return makeLineToken("qfq-variable-error");
                } else {
                    stream.backUp(2);
                    popTokenizer(state, stream);
                    return state.tokenize(stream, state);
                }
            } else if (ch == '"' || ch == "'") { // string
                state.tokenize = tokenString(ch);
                return state.tokenize(stream, state);
            } else if ( (ch == state.sep.start && (isFirst || state.foundQfqLevelName) || // opening brace: preceded by a qfq level name (could be from previous line) or nothing
                    ch == state.sep.end && isFirst) && // closing brace: preceded by nothing
                (stream.eatSpace() || stream.eol()) ) { // both: followed by nothing
                state.lvlNesting += ch == state.sep.start ? 1 : -1;
                state.foundQfqLevelName = false;
                resetSql(state, stream, false, true);
                if (state.lvlNesting < 0) {
                    state.lvlNesting = 0;
                    return makeLineToken("nesting-error");
                }
                return makeLineToken("qfq-brace", ch);
            } else if (ch == "<" && stream.match("!--") && !sqlMode) { // html comment
                state.tokenize = tokenComment("-", "->");
                return state.tokenize(stream, state);
            } else if (ch == "<") {
                stream.eat(/^\//); // potential closing tag
                return makeLineToken(stream.current(), stream.current()); // later used for rudimentary recognition of opening and closing html nodes
            } else if (ch == "/" && stream.match("*")) {
                state.tokenize = tokenComment("*", "/");
                return state.tokenize(stream, state);
            } else if (/[\[\]{}\(\)]/.test(ch)) { // log potential operators (not all of them used/necessary)
                return makeLineToken("operator", ch);
            } else if (/[,;\:\.]/.test(ch)) { // log some more characters
                return makeLineToken(ch, ch);
            }
        }

        function tokenString(quote) {
            return function(stream, state) {
                var escaped = false, ch;
                while ((ch = stream.next()) != null) {
                    if (ch == quote && !escaped) break;
                    if (ch == "{" && stream.match("{")) {
                        // qfq variable inside a string
                        // - interrupt string processing to process qfq variable
                        // - afterwards resume string processing
                        stream.backUp(2);
                        pushTokenizer(state, stream, tokenQfqVariable);
                        state.alreadyPushedTokenizer = true;
                        return makeLineToken("string");
                    }
                    escaped = !escaped && ch == "\\";
                }
                if (ch === quote) {
                    state.tokenize = tokenBase;
                }
                return makeLineToken("string");
            };
        }

        function tokenQfqVariable(stream, state) {
            var ch = stream.next();
            while (ch) {
                if (ch == "{" && stream.eat("{")) {
                    if(!state.alreadyPushedTokenizer) {
                        pushTokenizer(state, stream, tokenQfqVariable);
                    }
                    state.alreadyPushedTokenizer = false;
                    state.qfqVarNesting++;
                    if (stream.match(new RegExp(regExp.qfqSqlVariable(), "i"), false, true)) {
                        pushTokenizer(state, stream, tokenBase);
                        resetSql(state, stream, true, false);
                        return makeLineToken("qfq-variable");
                    }
                } else if (ch == "}" && stream.match("}")) {
                    state.qfqVarNesting--;
                    popTokenizer(state, stream);
                    return makeLineToken("qfq-variable");
                }
                ch = stream.next();
            }
            return makeLineToken("qfq-variable");
        }

        // used for multiline comments only (mysql and js /*multiline comments*/)
        function tokenComment(closingFirstChar, closingRest) {
            return function (stream, state) {
                var ch = stream.next();
                while (ch) {
                    if (ch == closingFirstChar && stream.match(closingRest)) {
                        state.tokenize = tokenBase;
                        break;
                    }
                    ch = stream.next();
                }
                return makeLineToken("multiline-comment");
            };
        }

        // Interface helpers
        function matchSeparator(start) {
            return closingSeparators[openingSeparators.indexOf(start)];
        }
        function setMaybeSeparator(sep, maybeSeparator) {
            if (openingSeparators.includes(maybeSeparator)) {
                sep.start = maybeSeparator;
                sep.end = matchSeparator(maybeSeparator);
            }
        }

        function completionHint(moveCursor) {
            return function(cm, data, completion) {
                cm.replaceRange(completion.text, completion.from || data.from,
                    completion.to || data.to, "complete");
                cm.setCursor(cm.getCursor().line, cm.getCursor().ch + moveCursor);
            }
        }

        function nextIsNestingHtmlTag(lineTokens, i) {
            if (lineTokens.length > i + 1)
                return lineTokens[i+1].type === "html-tag" && lineTokens[i+1].content !== "br";
            return false;
        }
        function getHtmlTagBalance(lineTokens) {
            var bal = 0
            for (var i = 0; i < lineTokens.length; ++i) {
                if (lineTokens[i].content === "<") {
                    bal += nextIsNestingHtmlTag(lineTokens, i);
                } else if (lineTokens[i].content === "</" && i >= 2) {
                    // if the line starts with a closing tag, the corresponding un-indent should have already been done
                    bal -= nextIsNestingHtmlTag(lineTokens, i);
                }
            }
            return bal;
        }

        function getStyle(lineToken) {
            if (!lineToken) return null;
            var styleDef = {
                "qfq-base": "strong keyword",
                "qfq-level": "strong keyword",
                "qfq-level-name": "strong tag",
                "qfq-brace": "strong tag",
                "qfq-variable": "positive",
                "qfq-sql-variable": "positive",
                "qfq-variable-error": "error",
                "qfq-comment": "comment",
                "sql-function": "variable-3",
                "sql-reserved": "def",
                "sql-keyword": "builtin",
                "sql-atom": "atom",
                "db-table": "qualifier",
                "db-table-parsed": "qualifier",
                "db-column": "type",
                "db-column-parsed": "type",
                "sql-comment": "comment meta",
                "multiline-comment": "comment meta",
                "number": "number",
                "html-tag": "variable-3",
                "qfq-form-vars": "attribute",
                "html-attr": "attribute",
                "word": "variable",
                "todo": "todo",
                "nesting-error": "error",
                "string": "string-2",
                "operator": "none",
                /*for hinting*/
                "qfq-variable-suggestion": "type"
            };
            return styleDef[lineToken.type];
        }

        function resetSql(state, stream, isActive, maybeTerminatesSql) {
            var Pos = { line: stream.lineOracle.line, ch: stream.start };
            if (state.sql.isActive && maybeTerminatesSql) saveSqlContext(state, stream); // save previous sql statement
            state.sqlCount++;
            state.sql = newSql(state.sqlCount, isActive);
            if (isActive) {
                state.sql.baseIndent = stream.pos + indentUnit;
                state.sql.startPos = Pos; // only for debugging
            }
        }
        // this function saves all sql contexts for later reference (hinting)
        // it should only be executed at the end of each sql statement
        function saveSqlContext(state, stream) {
            var Pos = { line: stream.lineOracle.line, ch: stream.start };
            state.sql.endPos = Pos;
            state.sqlAll[state.sql.sqlNumber] = state.sql;
        }
        function newSql(sqlNumber, isActive) {
            return {
                startPos: {line: 0, ch: 0}, endPos: {line: 0, ch: 0}, // currently only used for debugging purposes
                sqlNumber: sqlNumber,
                baseIndent: 0,
                isActive: isActive,
                passedFROM: false, // used to distinguish AS column_name from AS table_name
                columnAliases: [], // remembers all column names defined using the "AS column_names" syntax
                tableAliases: {}
            };
        }

        function popTokenizer(state, stream) {
            state.tokenize = state.tokenizerStack.pop() || tokenBase;
            if (state.sql.isActive) saveSqlContext(state, stream);
            if (state.sqlStack.length) {
                state.sql = state.sqlStack.pop();
            } else {
                resetSql(state, stream, false, false);
            }
        }
        function pushTokenizer(state, stream, newTokenize) {
            state.tokenizerStack.push(state.tokenize);
            state.tokenize = newTokenize;
            state.sqlStack.push(state.sql);
            resetSql(state, stream, false, false);
        }

        function hasQfqLevelToken(lineTokens) {
            for (var t in lineTokens)
                if (lineTokens[t].type === "qfq-level")
                    return true;
            return false;
        }

        //// Interface ////
        return {
            startState: function() {
                // technically only need to do this once (not once for every parse)
                CodeMirror.registerHelper("hint", "qfq", hintQfq);
                return {
                    tokenize: tokenBase,
                    tokenizerStack: [], // used to nest different tokenizer modes
                    lineTokens: [], // array of tokens found on the current line
                    sep: { start: "{", end: "}" },
                    lvlNesting: 0, // e.g. 10.10.sql
                    qfqVarNesting: 0, // e.g. {{test{{num}}:R0}}
                    qfqVarSuggestions: [],
                    columnsParsed: [], tablesParsed: [], // only used if no dbSchema defined (e.g. table.column notation)
                    alreadyPushedTokenizer: false,
                    predictIndent: 0,
                    foundQfqLevelName: false,
                    qfqLevelIndentColumn: 0,
                    sqlCount: 0,
                    sql: newSql(0, false),
                    sqlStack: [], // to save a sql state in case of nesting (such as {{SELECT ...}}
                    sqlAll: { } // array of all sql states
                };
            },

            token: function(stream, state) {
                var isTokenString = state.tokenize != tokenComment &&
                    state.tokenize != tokenQfqVariable && state.tokenize != tokenBase;
                if (stream.sol()) {
                    state.lineTokens = [];
                    if (!state.sql.isActive && isTokenString) { // interrupt multiline strings in some cases
                        var line = stream.string;
                        var isQfqLevelKeyword = new RegExp(regExp.qfqLevelKeyword(state.lvlNesting > 0)).test(line);
                        var isQfqBaseKeyword = new RegExp(regExp.qfqBaseKeyword).test(line) && state.lvlNesting === 0;
                        var isQfqOpeningLevel = new RegExp(regExp.qfqOpeningLevel(state.sep)).test(line);
                        var isQfqClosingLevel = new RegExp(regExp.qfqClosingLevel(state.sep)).test(line);
                        if (isQfqLevelKeyword || isQfqBaseKeyword || isQfqOpeningLevel || isQfqClosingLevel) {
                            state.tokenize = tokenBase;
                        }
                    }
                }
                var eatingSpaces = stream.eatSpace(), lineToken;

                if (!eatingSpaces) {
                    // LET'S TOKENIZE !!!
                    lineToken = state.tokenize(stream, state);
                    if (lineToken) state.lineTokens.push(lineToken);
                }

                if (stream.eol()) {
                    if (state.sql.isActive && stream.lookAhead(1) === undefined) saveSqlContext(state, stream);
                    var ind = stream.indentation();
                    var previousContent = state.lineTokens.length ? state.lineTokens[state.lineTokens.length - 1].content : null;
                    if (previousContent === state.sep.start || state.lineTokens.length === 0) {
                        // for QFQ separator braces or empty lines: reset indent
                        ind = state.lvlNesting * indentUnit;
                    } else if(state.sql.isActive) {
                        ind = state.sql.baseIndent + indentUnit;
                    } else if (hasQfqLevelToken(state.lineTokens)) {
                        // for QFQ level keywords (e.g. sql = ): predict indent next line to end of = sign
                        ind = state.qfqLevelIndentColumn;
                    } else if (openingSeparators.includes(previousContent) && previousContent !== state.sep.start) {
                        // for non-qfq opening braces: increase existing indent by one unit
                        ind += indentUnit;
                    }
                    if (!state.sql.isActive) {
                        // check if indent for html tags
                        var bal = getHtmlTagBalance(state.lineTokens);
                        ind += indentUnit * bal;
                    }
                    state.predictIndent = ind;
                }

                return eatingSpaces ? null : getStyle(lineToken);
            },

            indent: function(state, textAfter) {
                var isQfqLevelKeyword = new RegExp(regExp.qfqLevelKeyword(state.lvlNesting > 0)).test(textAfter);
                var isQfqBaseKeyword = new RegExp(regExp.qfqBaseKeyword).test(textAfter) && state.lvlNesting === 0;
                var isQfqOpeningLevel = new RegExp(regExp.qfqOpeningLevel(state.sep)).test(textAfter);
                var isQfqClosingLevel = new RegExp(regExp.qfqClosingLevel(state.sep)).test(textAfter);
                var isSqlBaseIndentKeyword = new RegExp(regExp.sqlBaseIndentKeyword, "i").test(textAfter);

                // if we suspect the indentation event was triggered because of slightly inaccurate electricInput, cancel
                if (new RegExp(regExp.qfqLevelKeyword() + '$', "i").test(textAfter) && !isQfqLevelKeyword ||
                    new RegExp(regExp.qfqBaseKeyword + '$', "i").test(textAfter) && !isQfqBaseKeyword ||
                    new RegExp(regExp.qfqOpeningLevel()).test(textAfter) && !isQfqOpeningLevel ||
                    isSqlBaseIndentKeyword && !state.sql.isActive
                ) {
                    return CodeMirror.Pass;
                } else if (isQfqLevelKeyword ||
                    isQfqBaseKeyword && state.lvlNesting === 0 ||
                    isQfqOpeningLevel ||
                    isQfqClosingLevel ||
                    textAfter.startsWith('#')) {
                    // Reset indenting for
                    // - qfq level keywords (e.g. " 10.10.sql = " or " head = ")
                    // - qfq base keywords (e.g. "form = ")
                    // - new level numbers (e.g. " 10 {} " or " {}")
                    // - closing delimiter (e.g. " }")
                    // - QFQ comments (starting with #)
                    return (state.lvlNesting - isQfqClosingLevel) * indentUnit;
                } else if (closingSeparators.includes(textAfter) || // closing brace (of any kind)
                    textAfter.startsWith("</") /*closing html tag*/ ) {
                    return state.predictIndent - indentUnit;
                } else if (isSqlBaseIndentKeyword) {
                    return state.sql.baseIndent;
                } else {
                    return state.predictIndent;
                }
            },

            // similar rules to above reset indenting rules
            // however, need to match all possible braces because we don't know the actual separator at this point
            electricInput: new RegExp(regExp.qfqLevelKeyword() + '$' +
                '|' + regExp.qfqBaseKeyword + '$' +
                '|' + regExp.qfqOpeningLevel() +
                '|' + regExp.qfqClosingLevel() +
                '|' + regExp.sqlBaseIndentKeyword + '$' +
                '|^\\s*</$' +
                '|^\\s*<[a-z]$' +
                '|^\\s*#$', "i"),

            closeBrackets: { pairs: "()<>{}[]" }, // couldn't get the <> working, seems to be a problem with closebrackets.js ensureBound function not getting called with the correct config string (otherwise this config is working)
        };

        function replaceQfqVars(text, placeholder = 'var') {
            for (var v = 0; v < 5; ++v)
                text = text.replace(/\{\{[^\{\}]*\}\}/, placeholder);
            return text;
        }

        function hintQfq(cm) {
            // this function has been copied and adapted from codemirror/addon/hint/anyhint.js
            // hinting has to parse the code in a similar way to the mode (syntax highlighting),
            // but because of codemirror's setup it needs to be done separately and with a slightly different approach

            // Performance (and "logic duplication") could potentially be improved by making better use of cm.getStateBefore/After()

            // setup, look around in context
            var cur = cm.getCursor(), curLine = cm.getLine(cur.line), curToken = cm.getTokenAt(cur);
            var end = cur.ch, start = end;
            while (start && /\w+/.test(curLine.charAt(start - 1))) --start;
            var curWord = curLine.slice(start, end);
            var from = CodeMirror.Pos(cur.line, start), to = CodeMirror.Pos(cur.line, end);
            var beforeText = curLine.slice(0, start), afterText = curLine.slice(end);
            var beforeChar = beforeText.slice(-1) || " ";
            var beforeWord = (beforeText.slice(0,-1).match(/\b(\w+)$/) || [""])[0]; // used for table.column recognition

            var lineTokens = curToken.state.lineTokens;
            var currentLineToken = lineTokens[lineTokens.length-1] || makeLineToken();
            var previousLineToken = lineTokens[lineTokens.length-2] || makeLineToken();
            var hintOptions = [], hintTypes = [];

            // Get the table/column aliases for the current sql statement
            var endState = cm.getStateAfter(cm.lastLine());
            var curTableAliases = {};

            // some keywords were parsed by the qfq syntax highlighter and saved in separate arrays
            // copy them over to the keywords array for normalized processing
            keywords["qfq-variable-suggestion"] = [...curToken.state.qfqVarSuggestions, ...keywords["qfq-store-variables"]];
            keywords["db-table-parsed"] = [...endState.tablesParsed]; // if no dbSchema
            keywords["db-column-parsed"] = [...endState.columnsParsed]; // if no dbSchema

            if (["qfq-comment", "multiline-comment", "sql-comment", "string"].includes(currentLineToken.type)) {
                return; // no suggestions inside comments or strings
            } else if (currentLineToken.type === "qfq-variable") {
                // sanitize beforeText -> replace all closed qfq variables with placeholder text
                beforeText = replaceQfqVars(beforeText);
                // estimate if a regular qfq variable (e.g. var:RE:alnumx) or something else (e.g. {{'...' AS _link}})
                var varContents = beforeText.slice(beforeText.lastIndexOf("{{")+2);
                if (/^&?[\w:\-\'!]*$/.test(varContents)) {
                    // process regular qfq variable
                    var colonCount = varContents.split(':').length - 1;
                    if (colonCount == 0) hintTypes.push("qfq-variable-suggestion");
                    else if (colonCount === 2) hintTypes.push("qfq-sanitize");
                    else if (colonCount > 5) return null;
                    else {
                        var titles = {1: '(Store)', 3: '(Escape/Action)', 4: '(Default)', 5: '(Message violate)'};
                        var varList = [{text: '', displayText: titles[colonCount]}];
                        if (colonCount === 1) varList.push({text: '', displayText: keywords["qfq-store"].join(" ")});
                        if (colonCount === 3) varList.push({text: '', displayText: keywords["qfq-escape"].join(" ")});
                        return {list: varList, from: to, to: to};
                    }
                } else { // unusual qfq variables
                    return null; // currently no hinting for other unusual qfq variables
                }
            } else {
                var curIsSqlMode = curToken.state.sql.isActive;
                var curLvlNesting = curToken.state.lvlNesting;
                if (curIsSqlMode && hasDbSchema && beforeChar === ".") {
                    // if we have an actual database schema, only add the table-in-question's columns
                    // do this here to make sure the columns are listed as the first options
                    hintTypes.push("db-column");
                }
                if (beforeText.trim() === "" && curLvlNesting === 0) {
                    hintTypes.push("qfq-base");
                    hintTypes.push("qfq-form-vars");
                }
                if (/^\s*(\w+\.)+$/.test(beforeText) && curLvlNesting === 0 ||
                    /^\s*(\w+\.)*$/.test(beforeText) && curLvlNesting > 0)
                    hintTypes.push("qfq-level");
                if (curIsSqlMode) {
                    var curSqlNumber = curToken.state.sql.sqlNumber;
                    var curSqlContext = endState.sqlAll[curSqlNumber];
                    if (curSqlContext) {
                        curTableAliases = curSqlContext.tableAliases;
                        for (var tableAlias in curTableAliases)
                            pushNew(keywords["db-table-parsed"], tableAlias);
                        var curColumnAliases = curSqlContext.columnAliases;
                        for (var columnAlias of curColumnAliases)
                            pushNew(keywords["db-column-parsed"], columnAlias);
                    }
                    if (beforeChar === "." && !hasDbSchema) {
                        hintTypes.push("db-column-parsed");
                    } else if (beforeChar === " " &&
                        previousLineToken.content !== "as") {
                        hintTypes.push("db-table"); hintTypes.push("db-table-parsed");
                        hintTypes.push("db-column"); hintTypes.push("db-column-parsed");
                    }
                    if ([" ", "("].includes(beforeChar))
                        hintTypes = hintTypes.concat(["sql-reserved", "sql-keyword", "sql-atom", "sql-function"]);
                } else {
                    if (beforeChar === "<" || beforeText.slice(-2) === "</")
                        hintTypes.push("html-tag");
                    if (beforeChar === " ") hintTypes.push("html-attr");
                }
            }

            if (start === end &&
                ![".", "_", "<", ":"].includes(beforeChar) &&
                !["</", "{{"].includes(beforeText.slice(-2)) )
                return; // usually no hinting for empty strings

            // Now add different hintTypes, with potential specialized behavior per type
            for (var t of hintTypes) {
                for (var i = 0; i < keywords[t].length; ++i) {
                    var displayText = null;
                    var base = keywords[t][i];
                    var text = base;
                    if (t === "db-column" && hasDbSchema && beforeChar === "." &&
                        // check specific table.column definition if given
                        !(dbSchema.hasOwnProperty(beforeWord) && dbSchema[beforeWord].includes(text)) &&
                        !(dbSchema.hasOwnProperty(curTableAliases[beforeWord]) &&
                            dbSchema[curTableAliases[beforeWord]].includes(text)))
                        continue; // skip if the table in question doesn't have such a column

                    // Special: qfq-level with []-Proposals
                    if (t === "qfq-level") {
                        // Standard: "token = "
                        hintOptions.push({
                            text: base + " = ",
                            displayText: base + " = ",
                            className: getStyle({type: t}),
                            tokenType: t,
                            hint: completionHint(0)
                        });

                        // For lbeg/lend/libeg/liend added [] and [1]
                        if (BRACKETABLE_LEVEL_TOKENS.includes(base)) {
                            hintOptions.push({
                                text: base + "[] = ",
                                displayText: base + "[] = ",
                                className: getStyle({type: t}),
                                tokenType: t,
                                hint: completionHint(0)
                            });
                        }
                        continue;
                    }

                    if (t.startsWith("sql-")) text = text.toUpperCase();
                    if (t === "sql-function") text = text + "()";
                    if (["qfq-level", "qfq-base", "qfq-form-vars"].includes(t)) text = text + " = ";
                    if (t === "html-tag") {
                        text = text + ">";
                        if (beforeText.slice(-2) === "</") displayText = "</" + text;
                        if (beforeChar === "<") displayText = "<" + text;
                    }
                    hintOptions.push({
                        text: text,
                        displayText: displayText || text,
                        className: getStyle({type: t}),
                        tokenType: t,
                        hint: completionHint(t === "sql-function" ? -1 : 0)
                    });
                }
            }

            // Filter for matches
            var list = [];
            hintOptions.forEach((h) => {
                if (h.text.toUpperCase().startsWith(curWord.toUpperCase()))
                    list.push(h);
            });

            var onpick = function(completion) {
                if (["qfq-level", "qfq-base", "qfq-form-vars"].includes(completion.tokenType))
                    cm.execCommand("indentAuto");
            };
            var completions = { list: list, from: from, to: to };
            CodeMirror.on(completions, "pick", onpick);
            return completions;
        }

        function rxEscape(s){
            return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        }
    });

    CodeMirror.defineOption("qfq-hint", false, function(cm) {
        cm.on("keyup", function(cm, event){
            if ([32/*space*/, 57/*opening parenthesis*/].includes(event.keyCode)) {
                cm.execCommand("capitalizeSql");
            } else if (!cm.state.completionActive &&
                (event.keyCode >= 48 && event.keyCode <= 111 /*alphanumeric*/ ||
                    [189, 190, 191, 58, 60, 219/* _./:<{ */].includes(event.keyCode) ) ) {
                CodeMirror.commands.autocomplete(cm, null, {
                    completeSingle: false,
                    customKeys: { /*for hinting*/
                        "Up": function (cm, handle) { handle.moveFocus(-1); },
                        "Down": function (cm, handle) { handle.moveFocus(1); },
                        "Tab": function (cm, handle) { handle.pick(); },
                        "Esc": function (cm, handle) { handle.close(); }
                    }
                });
            }
        });
    });

    CodeMirror.defineOption("qfq-brackets", false, function(cm) {
        // matchBrackets needs to be loaded first in order for this to work
        // couldn't figure out how to pass a RegExp through JSON (which seems to be the proper way to configure this addon)
        if (cm.state.matchBrackets)
            cm.state.matchBrackets.bracketRegex = /[(){}[\]<>]/;
    });

    CodeMirror.defineOption("qfq-keymap", false, function(cm) {
        cm.setOption("extraKeys", {
            Tab: function(cm) {
                if (cm.somethingSelected()) {
                    cm.execCommand("indentMore");
                } else {
                    // insert one indentUnit worth of spaces
                    var spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
                    cm.replaceSelection(spaces);
                }
            },

            "Shift-Tab": function(cm) {
                cm.execCommand("indentLess");
            },
            "Ctrl-Space": function(cm) { // indent more by one space
                var previousIndentUnit = cm.getOption("indentUnit");
                cm.setOption("indentUnit", 1);
                cm.execCommand("indentMore");
                cm.setOption("indentUnit", previousIndentUnit);
            },
            "Ctrl-Enter": function (cm) {
                cm.execCommand("indentAuto");
            },
            "Cmd-Enter": function (cm) {
                cm.execCommand("indentAuto");
            },
            "Ctrl-/": function(cm) {
                cm.execCommand("commentToggle");
            },
            "Cmd-/": function(cm) {
                cm.execCommand("commentToggle");
            }
        })
    });
});