UNPKG

incr-regex-package

Version:

An incremental regular expression parser in JavaScript; useful for input validation, RegExp

606 lines (522 loc) 20.9 kB
/** * Copyright (c) 2016, Nurul Choudhury * * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. * */ // // Modified from https://github.com/insin/inputmask-core // This was originally written by insin - on GIT hub // The code worked fine for fixed formatted input mask, but is not so useful for // varible mask based on regular expression (RegExp) // That capability regires this implementation of Regexp, and provides incremental processing of regular expression // Amost the entire original code has been replaces but the original interfaces remain // "use strict"; import { assign, copy, arr_find } from "../utils"; import { incrRegEx } from "../incr-regex-v3"; import { isHolder, isMeta, isOptional } from "../regex-utils"; import { RxMatcher } from "../RxMatcher"; //import {DONE,MORE,MAYBE,FAILED} from '../rxtree'; //import {printExpr,printExprN,printExprQ} from "../rxprint"; function newSel(s,e) { e = e || s; return {start: Math.min(s,e), end: Math.max(s,e)}; } function selPlus(sel, x,y) { return clip(newSel(sel.start+x,sel.end+(y===undefined?x:y))); } function clip(sel, range,clipFnAfter, clipFnBefore) { clipFnAfter = clipFnAfter || ((x) => x); clipFnBefore = clipFnBefore || clipFnAfter; const clp = (x,r, clipFn) => clipFn(Math.max(Math.min(x,r.end),r.start)); return !range ? sel : { start: clp(sel.start, range, clipFnBefore), end: clp(sel.end, range, clipFnAfter)}; } function zero(x) { return !(x||0) ; } function selRange(sel) { return sel?sel.end - sel.start:0; } function zeroRange(sel) { return zero(selRange(sel)); } function backward(oldSelV, newSelV) { return (oldSelV.start > newSelV. start); } export class RXInputMask{ constructor(options) { options = assign({ pattern: null, selection: {start: 0, end: 0}, value: '', history: {data: [], index: null, lastOp: null} }, options); if (options.pattern === null) { throw new Error('RXInputMask: you must provide a pattern.'); } this.setPattern(options.pattern, { value: options.value, selection: options.selection, history: options.history }); } /** * Get the state of object * @returns { pattern: RegExp, selection: { start: int, end: int}, value: String} */ getState() { let options = { pattern: this.pattern.clone(), selection: selPlus(this.selection,0), value: this._getValue(), history: {data: [], index: null, lastOp: null} }; return options; } /** * Applies a single character of input based on the current selection. * @param {string} char * @return {boolean} true if a change has been made to value or selection as a * result of the input, false otherwise. */ input(char) { // Ignore additional input if the cursor's at the end of the pattern if (zeroRange(this.selection) && this.pattern.isDone(this.selection.start)) { // to do find out if we are at the end return false; } let [result, newSel, newPat] = this._input(char, this.selection, this.pattern); // returns [status:boolean, newSelection, newPattern] if( !result ) return false; let [valueBefore, selectionBefore, patternBefore] = [this._getValue(), this.selection, this.pattern]; this._lastOp = 'input'; this.selection = newSel; this.pattern = newPat; this.value = this.getValue(); // History if (this._historyIndex !== null) { // Took more input after undoing, so blow any subsequent history away //console.log('splice(', this._historyIndex, this._history.length - this._historyIndex, ')'); this._history.splice(this._historyIndex, this._history.length - this._historyIndex); this._historyIndex = null; } if (this._lastOp !== 'input' || !zeroRange(selectionBefore) || this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) { this._history.push({value: valueBefore, selection: selectionBefore, lastOp: this._lastOp, pattern: patternBefore}); } this._lastSelection = selectionBefore; return true; } /** * Internal method to add a character at the current position * move all the characters. When inserting subsequent characters * the syatem tries to take care of the fixed characters * this is the same as the public input() method but it does not update history * * returns: [status:boolean, newSelection:Selection, newPattern: RegExp] * */ _input(ch,selection, aPattern) { // check if we are under an empty slot, then we can set the value there without moving anything if( zeroRange(selection) && aPattern.emptyAt(selection.start)) { selection = selPlus(selection,0,1); ////newPattern.getFirstEditableAtOrAfter(selection.end+1); } let newPattern = aPattern.clone(); // copy the current let {start: startPos, end: endPos} = selection; selection = this._updateSelection(selection, newPattern.getFirstEditableAtOrAfter(selection.start)); // start from the first editable position endPos = newPattern.getFirstEditableAtOrAfter(selection.end); newPattern.updateFixed(startPos,endPos); // make sure all the fixed values are set let textAfterSelection = _after(newPattern,false,endPos); // get the raw value let inputIndex = selection.start; newPattern.setPos(inputIndex); newPattern.fixTracker(); if( ch !== undefined && !_skipAndMatch(newPattern,ch) ) { // but first make sure we did not enter a fixed character return [false, selection,newPattern]; } selection = this._updateSelection(selection, newPattern.getInputLength()); endPos = Math.max(endPos, selection.end); let newPos = newPattern.getFirstEditableAtOrAfter(newPattern.getInputLength()); // Put back the remainder // let resultPattern = this._insertRest(textAfterSelection, newPattern, inputIndex,endPos+1); _fillInFixedValuesAtEnd(resultPattern || newPattern); resultPattern.fixTracker(); // Advance the cursor to the next character return [true, newSel(newPos,newPos),resultPattern || newPattern]; } _insertRest(textToAdd, aPattern, inputIndex, endIndex) { function _ins(textPos, textToAdd, aPattern, inputIndex, endIndex) { if( textPos >= textToAdd.length) return aPattern; if(inputIndex > endIndex || aPattern.isDone()) return aPattern; let alt = aPattern.clone(); if( _skipAndMatch(alt, textToAdd.charAt(textPos) )) { let res = _ins(textPos+1, textToAdd, alt, alt.getInputLength()-1, endIndex); if( res !== undefined ) return res; } //console.log("rx", aPattern); while(_skipFixed(aPattern, false)); aPattern.match(undefined); return _ins(textPos, textToAdd, aPattern, inputIndex+1, endIndex); } //textToAdd = trimHolder(textToAdd); let retV = _ins(0, textToAdd, aPattern, inputIndex, endIndex+textToAdd.length); //while(aPattern.skipFixed(true)); return retV; } /** * Attempts to delete from the value based on the current cursor position or * selection. * @return {boolean} true if the value or selection changed as the result of * backspacing, false otherwise. */ backspace() { // If the cursor is at the start there's nothing to do let firstIx = this.pattern.getFirstEditableAtOrAfter(0); if (this.selection.start < firstIx || this.selection.end < firstIx) { return false; } let selectionBefore = copy(this.selection); let valueBefore = this._getValue(); let {start, end} =this.selection; // No range selected - work on the character preceding the cursor if (start === end) { start = this.pattern.getFirstEditableAtOrBefore(start-1); end = start+1; if( start < firstIx ) return; } // Range selected - delete characters and leave the cursor at the start of the selection else { //end = this.pattern.getFirstEditableAtOrBefore(end); start = this.pattern.getFirstEditableAtOrBefore( (end < start? end : start)); if( end === start) { end++; } if( end <= firstIx) return; } let result = this._input(undefined, newSel(start,end), this.pattern); let patternBefore = this.pattern; if( !result[0] ) return false; this.pattern = result[2]; this.selection.start = this.selection.end = start; //console.log("Before:", selectionBefore, " After:",this.selection); this.pattern.fixTracker(); // History if (this._historyIndex !== null) { // Took more input after undoing, so blow any subsequent history away this._history.splice(this._historyIndex, this._history.length - this._historyIndex); } if (this._lastOp !== 'backspace' || selectionBefore.start !== selectionBefore.end || this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) { this._history.push({value: valueBefore, selection: selectionBefore, lastOp: this._lastOp, pattern: patternBefore}); } this._lastOp = 'backspace'; this._lastSelection = copy(selectionBefore); return true; } del() { if(zeroRange(this.selection)) { this.right(this.selection); return this.backspace(); } else return this.backspace(); } /** * Attempts to paste a string of input at the current cursor position or over * the top of the current selection. * Invalid content at any position will cause the paste to be rejected, and it * may contain static parts of the mask's pattern. * @param {string} input * @return {boolean} true if the paste was successful, false otherwise. */ paste(input) { // This is necessary because we're just calling input() with each character // and rolling back if any were invalid, rather than checking up-front. var initialState = { value: this.value.slice(), selection: copy(this.selection), _lastOp: this._lastOp, _history: this._history.slice(), _historyIndex: this._historyIndex, _lastSelection: copy(this._lastSelection), pattern: this.pattern.clone() }; // If there are static characters at the start of the pattern and the cursor // or selection is within them, the static characters must match for a valid // paste. let rest = this.getRawValueAt(this.selection.end); // get raw value from the pattern this.pattern.setPos(this.selection.start); let insVal = this._setValueFrom(this.selection.start, input); this.selection.end = this.pattern.getInputLength(); if( !insVal || !this._setValueFrom(this.selection.end,rest) ) { assign(this, initialState); return false; } return true; } // History undo() { // If there is no history, or nothing more on the history stack, we can't undo if (this._history.length === 0 || this._historyIndex === 0) { return false; } var historyItem; if (this._historyIndex === null) { // Not currently undoing, set up the initial history index this._historyIndex = this._history.length - 1; historyItem = this._history[this._historyIndex]; // Add a new history entry if anything has changed since the last one, so we // can redo back to the initial state we started undoing from. var value = this._getValue(); if (historyItem.value !== value || historyItem.selection.start !== this.selection.start || historyItem.selection.end !== this.selection.end) { this._history.push({value: value, selection: copy(this.selection), lastOp: this._lastOp, startUndo: true, pattern: this.pattern.clone() }); } } else { historyItem = this._history[--this._historyIndex]; } this.pattern = historyItem.pattern; this.setValue(historyItem.value); this.selection = historyItem.selection; this._lastOp = historyItem.lastOp; return true; } redo() { if (this._history.length === 0 || this._historyIndex === null) { return false; } var historyItem = this._history[++this._historyIndex]; // If this is the last history item, we're done redoing if (this._historyIndex === this._history.length - 1) { this._historyIndex = null; // If the last history item was only added to start undoing, remove it if (historyItem.startUndo) { this._history.pop(); } } this.pattern = historyItem.pattern.clone(); this.setValue(historyItem.value); this.selection = historyItem.selection; this._lastOp = historyItem.lastOp; return true; } left(selection) { let sel = copy(selection); if( sel && zeroRange(sel) ) { sel.start = sel.end = selection.start-1; this.setSelection(sel); } return this; } right(selection) { let sel = copy(selection); if( sel && sel.start === sel.end) { sel.start = sel.end = selection.start+1; this.setSelection(sel); } return this; } // Getters & setters setPattern(pattern, options) { options = assign({ // selection: {start: 0, end: 0}, value: '' }, options); this.pattern = new RxMatcher(incrRegEx(pattern)); this.setValue(options.value); this.emptyValue = this.pattern.minChars(); this.setSelection(options.selection); while(this.skipFixed(true)); if( zeroRange(this.selection) && this.pattern.getInputTracker().length !== 0 ) { var ss = this._getValue(); this.setValue(ss); this.selection.start = this.selection.end = ss.length; } this._resetHistory(); return this; } select( low, high) { this.selection = ( high < low)?newSel(high,low):newSel(low,high); return this; } setSelection(selection) { let sel = selection===this.selection? this.selection:copy(selection); let old = this.selection || sel ; const fea = (x) => this.pattern.getFirstEditableAtOrAfter(x); const feb = (x) => this.pattern.getFirstEditableAtOrBefore(x); this.selection = old; let firstEditableIndex = fea(0); // first editable after let lastEditableIndex = this.pattern.lastEditableIndex(); let range = newSel(firstEditableIndex,lastEditableIndex); if (zeroRange(sel)) { this.selection = clip(sel,range,backward(this.selection, sel)?feb:fea); } else { this.selection = clip(sel,range,fea,feb); } return this; } _adjustSelection(sel,forward) { if( zeroRange(sel)) { return newSel(forward ? this.pattern.getFirstEditableAtOrAfter(sel.start) : this.pattern.getFirstEditableAtOrBefore(sel.start)); } return newSel(this.pattern.getFirstEditableAtOrBefore(sel.start), this.pattern.getFirstEditableAtOrAfter(sel.end)); } _setValueFrom(ix,str) { let newPattern = this.pattern.clone(); let success = true; if( ix !== undefined) newPattern.setPos(ix); for(let i = ix, j=0;j<str.length && success ; i++, j++ ) { let c = str.charAt(j); success &= _skipAndMatch(newPattern,c); } if( success ) { _fillInFixedValuesAtEnd(newPattern); this.pattern = newPattern; } return success; } setValue(value) { let lg = new Logger("RXInputMask:"); if(this.getValue() === value) { // lg.println("no change to:",value ).flush(); return true; } let workingPattern = this.pattern.clone(); if (value === null) { value = ''; } workingPattern.reset(); lg.println("iterate over",value,"length:",value.length); for(let i=0; i<value.length; i++) { let c = value.charAt(i); lg.print("index: ",i, "char:",c,"minChars:", workingPattern.minChars(), "sameAsRest",sameAsRest(value.substring(i),workingPattern.minChars()), "---"); if(sameAsRest(value.substring(i),workingPattern.minChars())) break; if( isHolder(c)) c = undefined; if(!_skipAndMatch(workingPattern,c)) { return false; } } //_fillInFixedValuesAtEnd(this.pattern); this.pattern = workingPattern; this.value = this.getValue(); return true; } minCharsList(flag) { //if( !flag ) throw new Error("flag should be true"); return this.pattern.minCharsList(flag); } getSelection() { return copy(this.selection); } _getValue() { return _after(this.pattern,true,0); } getValue() { return _after(this.pattern, true,0)+this.pattern.minChars(); //.valueWithMask(); //return this.pattern.rawValue(0); //return this._getValue() } getRawValue() { return _after(this.pattern,false,0); } getRawValueAt(ix) { return _after(this.pattern,false,ix); } reset() { this.pattern.reset(); this._resetHistory(); this.value = this.getValue(); this.selection.start = this.selection.end = 0; this.setSelection(this.selection); return this; } _resetHistory() { this._history = []; this._historyIndex = null; this._lastOp = null; this._lastSelection = copy(this.selection); return this; } _updateSelection(aSelection, start) { let res = copy(aSelection); res.start = start; if( start > res.end) res.end = start; return res; } skipFixed(flag) { return _skipFixed(this.pattern,flag); } isDone() { let pattern = this.pattern.clone(); let value = this.getValue(); let list = value.split(''); //console.log("isDone: ", value); if(arr_find((e) => isHolder(e),list )) return "MORE"; pattern.reset(); for(let i=0; i< list.length; i++) { if( isMeta(list[i]) ) continue; if( !pattern.match(list[i]) ) return "MORE"; } //console.log("isDone: state", pattern.stateStr()); return pattern.stateStr(); } } // *pattern Helpers function sameAsRest(str,rest) { if( str === rest ) return true; return false; //( isMeta(rest[0]) && str === rest.substring(1,rest.length) ) ; } function _fillInFixedValuesAtEnd(pattern) { let s = pattern.minChars(); let i = 0; for(;s.length > i && !isMeta(s.charAt(0)); i++) { if( ! pattern.match(s.charAt(0))) return i > 0; s = pattern.minChars(); } return i>0; } function _skipFixed(aPattern, onlyFixed) { let s = aPattern.minChars(); onlyFixed = !!onlyFixed; if( onlyFixed !== true && s.length > 1 && isOptional(s.charAt(0)) && !isMeta(s.charAt(1)) ) { if (aPattern.match(s.charAt(1))) return true; } else if( /* onlyFixed === true && */ s.length > 0 && !isMeta(s.charAt(0))) return aPattern.match(s.charAt(0)); return false; } function _skipAndMatch(aPattern, ch) { if(aPattern.match(ch)) return true; let backup = aPattern.clone(); while( _skipFixed(aPattern,false) ) { if( aPattern.match(ch) ) return true; } aPattern.reset(); aPattern.matchStr(_after(backup,true,0)); return false; } export function trimHolder(textToAdd) { let i = textToAdd.length -1; for(; i>=0 && isHolder(textToAdd.charAt(i)); i--); return textToAdd.substring(0,i+1); } export function _after(aPattern, all, ix) { /* public */ // get the input matched so far after ix. let tracker = aPattern.getInputTracker(); if(!ix) { let al = all?tracker:tracker.filter( e => e[1] === undefined); return al.map(e => e[0] ).join(''); } else { let al = tracker.filter( (e,i) => i>= ix && (all || e[1] === undefined)); return al.map(e => e[0] ).join(''); } } class Logger { constructor(X) { this.content = X || ""; } print(...s) { this.content += "," + (s||[]).map((a) => JSON.stringify(a)).join(" "); return this; } println(...s) { this.print.apply(this,s); this.content += "\n"; return this; } flush() { console.log(this.content); this.content = ""; } }