├── .eslintignore ├── .gitignore ├── .gitmodules ├── lib ├── Exceptions │ ├── Interpreter.js │ ├── Syntax.js │ ├── Builder.js │ └── Implementation.js ├── Builder │ ├── Capture.js │ ├── Optional.js │ ├── NonCapture.js │ ├── PositiveLookahead.js │ ├── NegativeLookahead.js │ └── EitherOf.js ├── SRL.js ├── Language │ ├── Methods │ │ ├── ToMethod.js │ │ ├── SimpleMethod.js │ │ ├── AsMethod.js │ │ ├── AndMethod.js │ │ ├── TimesMethod.js │ │ └── Method.js │ ├── Helpers │ │ ├── Literally.js │ │ ├── Cache.js │ │ ├── buildQuery.js │ │ ├── methodMatch.js │ │ └── parseParentheses.js │ └── Interpreter.js └── Builder.js ├── .editorconfig ├── .travis.yml ├── test ├── issue-17-test.js ├── issue-11-test.js ├── cache-test.js ├── exceptions-test.js ├── parseParentheses-test.js ├── interpreter-test.js ├── rules-test.js └── builder-test.js ├── package.json ├── LICENSE ├── README.md └── .eslintrc /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .vscode 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/rules"] 2 | path = test/rules 3 | url = https://github.com/SimpleRegex/Test-Rules 4 | -------------------------------------------------------------------------------- /lib/Exceptions/Interpreter.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class Interpreter extends Error { 4 | } 5 | 6 | module.exports = Interpreter 7 | -------------------------------------------------------------------------------- /lib/Exceptions/Syntax.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class SyntaxException extends Error { 4 | } 5 | 6 | module.exports = SyntaxException 7 | -------------------------------------------------------------------------------- /lib/Exceptions/Builder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class BuilderException extends Error { 4 | } 5 | 6 | module.exports = BuilderException 7 | -------------------------------------------------------------------------------- /lib/Exceptions/Implementation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class ImplementationException extends Error { 4 | } 5 | 6 | module.exports = ImplementationException 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 10 5 | - 12 6 | cache: 7 | - node_modules 8 | install: 9 | - npm install 10 | - npm install -g codecov 11 | script: 12 | - npm run lint 13 | - istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec 14 | - codecov 15 | 16 | -------------------------------------------------------------------------------- /lib/Builder/Capture.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class Capture extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired match group. */ 10 | this._group = '(%s)' 11 | } 12 | } 13 | 14 | module.exports = Capture 15 | -------------------------------------------------------------------------------- /lib/Builder/Optional.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class Optional extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired match group. */ 10 | this._group = '(?:%s)?' 11 | } 12 | } 13 | 14 | module.exports = Optional 15 | -------------------------------------------------------------------------------- /lib/Builder/NonCapture.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class NonCapture extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired non capture group. */ 10 | this._group = '(?:%s)' 11 | } 12 | } 13 | 14 | module.exports = NonCapture 15 | -------------------------------------------------------------------------------- /lib/Builder/PositiveLookahead.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class PositiveLookahead extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired match group. */ 10 | this._group = '(?=%s)' 11 | } 12 | } 13 | 14 | module.exports = PositiveLookahead 15 | -------------------------------------------------------------------------------- /lib/Builder/NegativeLookahead.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class NegativeLookahead extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired lookahead group. */ 10 | this._group = '(?!%s)' 11 | } 12 | } 13 | 14 | module.exports = NegativeLookahead 15 | -------------------------------------------------------------------------------- /lib/Builder/EitherOf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../Builder') 4 | 5 | class EitherOf extends Builder { 6 | constructor() { 7 | super() 8 | 9 | /** @var {string} _group Desired match group. */ 10 | this._group = '(?:%s)' 11 | 12 | /** @var {string} _implodeString String to join with. */ 13 | this._implodeString = '|' 14 | } 15 | } 16 | 17 | module.exports = EitherOf 18 | -------------------------------------------------------------------------------- /lib/SRL.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('./Builder') 4 | const Interpreter = require('./Language/Interpreter') 5 | 6 | /** 7 | * SRL facade for SRL Builder and SRL Language. 8 | * 9 | * @param {string} query 10 | * @return {Builder} 11 | */ 12 | function SRL(query) { 13 | return query && typeof query === 'string' ? 14 | new Interpreter(query).builder : 15 | new Builder() 16 | } 17 | 18 | module.exports = SRL 19 | -------------------------------------------------------------------------------- /test/issue-17-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const SRL = require('../') 5 | 6 | describe('Fix issue 17', () => { 7 | it('Capture group name assignment fails', () => { 8 | assert.doesNotThrow(() => { 9 | const query = new SRL('capture (literally "TEST") as test') 10 | const match = query.getMatch('WORD NOT HERE') 11 | assert.equal(match, null) 12 | }, TypeError) 13 | }) 14 | }) 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/Language/Methods/ToMethod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('./Method') 4 | 5 | /** 6 | * Method having simple parameter(s) ignoring "to". 7 | */ 8 | class ToMethod extends Method { 9 | /** 10 | * @inheritdoc 11 | */ 12 | setParameters(parameters) { 13 | parameters = parameters.filter((parameter) => { 14 | return typeof parameter !== 'string' || parameter.toLowerCase() !== 'to' 15 | }) 16 | 17 | return super.setParameters(parameters) 18 | } 19 | } 20 | 21 | module.exports = ToMethod 22 | 23 | -------------------------------------------------------------------------------- /lib/Language/Helpers/Literally.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Wrapper for literal strings that should not be split, tainted or interpreted in any way. 5 | */ 6 | class Literally { 7 | /** 8 | * @constructor 9 | * @param {string} string 10 | */ 11 | constructor(string) { 12 | // Just like stripslashes in PHP 13 | this._string = string.replace(/\\(.)/mg, '$1') 14 | } 15 | 16 | 17 | /** 18 | * @return {string} 19 | */ 20 | toString() { 21 | return this._string 22 | } 23 | } 24 | 25 | module.exports = Literally 26 | -------------------------------------------------------------------------------- /lib/Language/Methods/SimpleMethod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('./Method') 4 | const SyntaxException = require('../../Exceptions/Syntax') 5 | 6 | /** 7 | * Method having no parameters. Will throw SyntaxException if a parameter is provided. 8 | */ 9 | class SimpleMethod extends Method { 10 | /** 11 | * @inheritdoc 12 | */ 13 | setParameters(parameters) { 14 | if (parameters.length !== 0) { 15 | throw new SyntaxException('Invalid parameters.') 16 | } 17 | 18 | return this 19 | } 20 | } 21 | 22 | module.exports = SimpleMethod 23 | -------------------------------------------------------------------------------- /lib/Language/Methods/AsMethod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('./Method') 4 | 5 | /** 6 | * Method having simple parameter(s) ignoring "as". 7 | */ 8 | class AsMethod extends Method { 9 | /** 10 | * @inheritdoc 11 | */ 12 | setParameters(parameters) { 13 | parameters = parameters.filter((parameter) => { 14 | if (typeof parameter !== 'string') { 15 | return true 16 | } 17 | 18 | const lower = parameter.toLowerCase() 19 | return lower !== 'as' 20 | }) 21 | 22 | return super.setParameters(parameters) 23 | } 24 | } 25 | 26 | module.exports = AsMethod 27 | -------------------------------------------------------------------------------- /test/issue-11-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const SRL = require('../') 5 | 6 | describe('Fix issue 11', () => { 7 | it('Numerical quantifies & non-capturing group', () => { 8 | const query = new SRL('digit, exactly 5 times, (letter, twice) optional') 9 | assert.ok(query.isMatching('12345')) 10 | assert.ok(query.isMatching('12345aa')) 11 | }) 12 | 13 | it('Complicated case', () => { 14 | const query = new SRL('begin with, digit, exactly 5 times, ( literally \'-\', digit, exactly 4 times ), optional, must end') 15 | assert.ok(query.isMatching('12345-1234')) 16 | }) 17 | }) 18 | 19 | 20 | -------------------------------------------------------------------------------- /lib/Language/Methods/AndMethod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('./Method') 4 | 5 | /** 6 | * Method having simple parameter(s) ignoring "and" and "times". 7 | */ 8 | class AndMethod extends Method { 9 | /** 10 | * @inheritdoc 11 | */ 12 | setParameters(parameters) { 13 | parameters = parameters.filter((parameter) => { 14 | if (typeof parameter !== 'string') { 15 | return true 16 | } 17 | 18 | const lower = parameter.toLowerCase() 19 | return lower !== 'and' && lower !== 'times' && lower !== 'time' 20 | }) 21 | 22 | return super.setParameters(parameters) 23 | } 24 | } 25 | 26 | module.exports = AndMethod 27 | -------------------------------------------------------------------------------- /test/cache-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const Cache = require('../lib/Language/Helpers/Cache') 5 | const Interpreter = require('../lib/Language/Interpreter') 6 | 7 | describe('Cache', () => { 8 | it('Basic', () => { 9 | const re = {} 10 | Cache.set('test', re) 11 | assert.deepEqual(Cache.get('test'), re) 12 | }) 13 | 14 | it('In interpreter', () => { 15 | const RE = /(?:a)/g 16 | const query = new Interpreter('Literally "a"') 17 | assert.deepEqual(query.get(), RE) 18 | 19 | const query2 = new Interpreter('Literally "a"') 20 | assert.notEqual(query2, RE) 21 | 22 | assert(query !== query2) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /lib/Language/Helpers/Cache.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Builder = require('../../Builder') 4 | const _cache = {} 5 | 6 | /** 7 | * Temporary cache for already built SRL queries to speed up loops. 8 | */ 9 | const Cache = { 10 | /** 11 | * Set Builder for SRL to cache. 12 | * 13 | * @param {string} query 14 | * @param {Builder} builder 15 | */ 16 | set(query, builder) { 17 | _cache[query] = builder 18 | }, 19 | 20 | /** 21 | * Get SRL from cache, or return new Builder. 22 | * 23 | * @param {string} query 24 | * @return {Builder} 25 | */ 26 | get(query) { 27 | return _cache[query] || new Builder() 28 | }, 29 | 30 | /** 31 | * Validate if current SRL is a already in cache. 32 | * 33 | * @param {string} query 34 | * @return {boolean} 35 | */ 36 | has(query) { 37 | return !!_cache[query] 38 | } 39 | } 40 | 41 | module.exports = Cache 42 | -------------------------------------------------------------------------------- /lib/Language/Methods/TimesMethod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('./Method') 4 | const SyntaxException = require('../../Exceptions/Syntax') 5 | 6 | /** 7 | * Method having one or two parameters. First is simple, ignoring second "time" or "times". Will throw SyntaxException if more parameters provided. 8 | */ 9 | class TimeMethod extends Method { 10 | /** 11 | * @inheritdoc 12 | */ 13 | setParameters(parameters) { 14 | parameters = parameters.filter((parameter) => { 15 | if (typeof parameter !== 'string') { 16 | return true 17 | } 18 | 19 | const lower = parameter.toLowerCase() 20 | 21 | return lower !== 'times' && lower !== 'time' 22 | }) 23 | 24 | if (parameters.length > 1) { 25 | throw new SyntaxException('Invalid parameter.') 26 | } 27 | 28 | return super.setParameters(parameters) 29 | } 30 | } 31 | 32 | module.exports = TimeMethod 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srl", 3 | "version": "0.2.3", 4 | "description": "Simple Regex Language", 5 | "main": "lib/SRL.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "lint": "eslint lib --fix", 9 | "coverage": "istanbul cover _mocha" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/SimpleRegex/SRL-JavaScript.git" 14 | }, 15 | "keywords": [ 16 | "srl", 17 | "regex", 18 | "re", 19 | "simpleregex" 20 | ], 21 | "author": "Simple Regex Language", 22 | "maintainers": [ 23 | "Boom Lee " 24 | ], 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/SimpleRegex/SRL-JavaScript/issues" 28 | }, 29 | "engines": { 30 | "node": ">= 6.0.0" 31 | }, 32 | "homepage": "https://simple-regex.com", 33 | "devDependencies": { 34 | "eslint": "^6.8.0", 35 | "istanbul": "^0.4.5", 36 | "mocha": "^3.0.2", 37 | "mocha-lcov-reporter": "^1.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Simple Regex Language 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/exceptions-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const SRL = require('../') 5 | 6 | const BuilderException = require('../lib/Exceptions/Builder') 7 | const ImplementationException = require('../lib/Exceptions/Implementation') 8 | 9 | describe('Builder Exceptions', () => { 10 | it('Raw method', () => { 11 | const regex = new SRL('Literally "a"') 12 | 13 | assert['throws'](() => { 14 | regex.raw(')') 15 | }, (error) => { 16 | return error instanceof BuilderException && 17 | error.message === 'Adding raw would invalidate this regular expression. Reverted.' && 18 | regex.test('a') 19 | }) 20 | }) 21 | 22 | 23 | }) 24 | 25 | describe('Implementation Exception', () => { 26 | it('Lazy Method', () => { 27 | const regex = new SRL('Literally "a"') 28 | 29 | assert['throws'](() => { 30 | regex.lazy() 31 | }, (error) => { 32 | return error instanceof ImplementationException && 33 | error.message === 'Cannot apply laziness at this point. Only applicable after quantifier.' 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /lib/Language/Helpers/buildQuery.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Method = require('../Methods/Method') 4 | const Builder = require('../../Builder') 5 | const NonCapture = require('../../Builder/NonCapture') 6 | const SyntaxException = require('../../Exceptions/Syntax') 7 | 8 | /** 9 | * After the query was resolved, it can be built and thus executed. 10 | * 11 | * @param array $query 12 | * @param Builder|null $builder If no Builder is given, the default Builder will be taken. 13 | * @return Builder 14 | * @throws SyntaxException 15 | */ 16 | function buildQuery(query, builder = new Builder()) { 17 | for (let i = 0; i < query.length; i++) { 18 | const method = query[i] 19 | 20 | if (Array.isArray(method)) { 21 | builder.and(buildQuery(method, new NonCapture())) 22 | continue 23 | } 24 | 25 | if (!method instanceof Method) { 26 | // At this point, there should only be methods left, since all parameters are already taken care of. 27 | // If that's not the case, something didn't work out. 28 | throw new SyntaxException(`Unexpected statement: ${method}`) 29 | } 30 | 31 | const parameters = [] 32 | // If there are parameters, walk through them and apply them if they don't start a new method. 33 | while (query[i + 1] && !(query[i + 1] instanceof Method)) { 34 | parameters.push(query[i + 1]) 35 | 36 | // Since the parameters will be appended to the method object, they are already parsed and can be 37 | // removed from further parsing. Don't use unset to keep keys incrementing. 38 | query.splice(i + 1, 1) 39 | } 40 | 41 | try { 42 | // Now, append that method to the builder object. 43 | method.setParameters(parameters).callMethodOn(builder) 44 | } catch (e) { 45 | const lastIndex = parameters.length - 1 46 | if (Array.isArray(parameters[lastIndex])) { 47 | if (lastIndex !== 0) { 48 | method.setParameters(parameters.slice(0, lastIndex)) 49 | } 50 | method.callMethodOn(builder) 51 | builder.and(buildQuery(parameters[lastIndex], new NonCapture())) 52 | } else { 53 | throw new SyntaxException(`Invalid parameter given for ${method.origin}`) 54 | } 55 | } 56 | } 57 | 58 | return builder 59 | } 60 | 61 | module.exports = buildQuery 62 | -------------------------------------------------------------------------------- /test/parseParentheses-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const Literally = require('../lib/Language/Helpers/Literally') 5 | const parseParentheses = require('../lib/Language/Helpers/parseParentheses') 6 | 7 | describe('ParseParentheses Test', () => { 8 | 9 | it('Default', () => { 10 | assert.deepEqual(parseParentheses('foo (bar) baz'), [ 'foo', [ 'bar' ], 'baz' ]) 11 | 12 | assert.deepEqual(parseParentheses('(foo (bar) baz)'), [ 'foo', [ 'bar' ], 'baz' ]) 13 | 14 | assert.deepEqual(parseParentheses('foo (bar)'), [ 'foo', [ 'bar'] ]) 15 | 16 | assert.deepEqual(parseParentheses('(foo)bar'), [ [ 'foo' ], 'bar' ]) 17 | 18 | assert.deepEqual(parseParentheses('foo (0)'), [ 'foo', [ '0' ] ]) 19 | 20 | assert.deepEqual( 21 | parseParentheses('foo (bar (nested)) baz'), 22 | [ 'foo', [ 'bar', [ 'nested' ] ], 'baz' ] 23 | ) 24 | 25 | assert.deepEqual( 26 | parseParentheses('foo boo (bar (nested) something) baz (bar (foo foo))'), 27 | [ 'foo boo', [ 'bar', [ 'nested' ], 'something' ], 'baz', [ 'bar', [ 'foo foo' ] ] ] 28 | ) 29 | }) 30 | 31 | it('Escaping', () => { 32 | assert.deepEqual( 33 | parseParentheses('foo (bar "(bla)") baz'), 34 | [ 'foo', [ 'bar', new Literally('(bla)') ], 'baz' ] 35 | ) 36 | 37 | assert.deepEqual( 38 | parseParentheses('sample "foo" bar'), 39 | [ 'sample', new Literally('foo'), 'bar' ] 40 | ) 41 | 42 | assert.deepEqual( 43 | parseParentheses('sample "foo"'), 44 | [ 'sample', new Literally('foo') ] 45 | ) 46 | 47 | assert.deepEqual( 48 | parseParentheses('bar "(b\\"la)" baz'), 49 | [ 'bar', new Literally('(b\\"la)'), 'baz' ] 50 | ) 51 | 52 | assert.deepEqual( 53 | parseParentheses('foo "ba\'r" baz'), 54 | [ 'foo', new Literally('ba\'r'), 'baz' ] 55 | ) 56 | 57 | assert.deepEqual( 58 | parseParentheses('foo (bar \'(b\\\'la)\') baz'), 59 | [ 'foo', [ 'bar', new Literally('(b\\\'la)') ], 'baz'] 60 | ) 61 | 62 | assert.deepEqual( 63 | parseParentheses('bar "b\\\\\" (la) baz'), 64 | [ 'bar', new Literally('b\\\\'), [ 'la' ], 'baz' ] 65 | ) 66 | 67 | assert.deepEqual( 68 | parseParentheses('"fizz" and "buzz" (with) "bar"'), 69 | [ new Literally('fizz'), 'and', new Literally('buzz'), [ 'with' ], new Literally('bar') ] 70 | ) 71 | 72 | assert.deepEqual( 73 | parseParentheses('foo \\"boo (bar (nes"ted) s\\"om\\"")ething) baz (bar (foo foo))'), 74 | [ 'foo \\"boo', [ 'bar', [ 'nes', new Literally('ted) s"om"') ], 'ething' ], 'baz', [ 'bar', [ 'foo foo' ] ] ] 75 | ) 76 | }) 77 | 78 | it('Empty', () => { 79 | assert.deepEqual(parseParentheses(''), []) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /lib/Language/Methods/Method.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const SyntaxException = require('../../Exceptions/Syntax') 4 | const ImplementationException = require('../../Exceptions/Implementation') 5 | 6 | const Literally = require('../Helpers/Literally') 7 | 8 | class Method { 9 | /** 10 | * @constructor 11 | * @param {string} origin 12 | * @param {string} methodName 13 | * @param {function} buildQuery 14 | */ 15 | constructor(origin, methodName, buildQuery) { 16 | /** @var {string} origin Contains the original method name (case-sensitive). */ 17 | this.origin = origin 18 | /** @var {string} methodName Contains the method name to execute. */ 19 | this.methodName = methodName 20 | 21 | /** @var {array} parameters Contains the parsed parameters to pass on execution. */ 22 | this.parameters = [] 23 | /** @var {array} executedCallbacks Contains all executed callbacks for that method. Helps finding "lost" groups. */ 24 | this.executedCallbacks = [] 25 | 26 | /** @var {function} buildQuery Reference to buildQuery since js DON'T support circular dependency well */ 27 | this.buildQuery = buildQuery 28 | } 29 | 30 | /** 31 | * @param {Builder} builder 32 | * @throws {SyntaxException} 33 | * @return {Builder|mixed} 34 | */ 35 | callMethodOn(builder) { 36 | const methodName = this.methodName 37 | const parameters = this.parameters 38 | 39 | try { 40 | builder[methodName].apply(builder, parameters) 41 | 42 | parameters.forEach((parameter, index) => { 43 | if ( 44 | typeof parameter === 'function' && 45 | !this.executedCallbacks.includes(index) 46 | ) { 47 | // Callback wasn't executed, but expected to. Assuming parentheses without method, so let's "and" it. 48 | builder.group(parameter) 49 | } 50 | }) 51 | } catch (e) { 52 | if (e instanceof ImplementationException) { 53 | throw new SyntaxException(e.message) 54 | } else { 55 | throw new SyntaxException(`'${methodName}' does not allow the use of sub-queries.`) 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Set and parse raw parameters for method. 62 | * 63 | * @param {array} params 64 | * @throws {SyntaxException} 65 | * @return {Method} 66 | */ 67 | setParameters(parameters) { 68 | this.parameters = parameters.map((parameter, index) => { 69 | if (parameter instanceof Literally) { 70 | return parameter.toString() 71 | } else if (Array.isArray(parameter)) { 72 | // Assuming the user wanted to start a sub-query. This means, we'll create a callback for them. 73 | return (builder) => { 74 | this.executedCallbacks.push(index) 75 | this.buildQuery(parameter, builder) 76 | } 77 | } else { 78 | return parameter 79 | } 80 | }) 81 | 82 | return this 83 | } 84 | } 85 | 86 | module.exports = Method 87 | -------------------------------------------------------------------------------- /lib/Language/Interpreter.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Cache = require('./Helpers/Cache') 4 | const Literally = require('./Helpers/Literally') 5 | const parseParentheses = require('./Helpers/parseParentheses') 6 | const buildQuery = require('./Helpers/buildQuery') 7 | const methodMatch = require('./Helpers/methodMatch') 8 | 9 | const InterpreterException = require('../Exceptions/Interpreter') 10 | 11 | class Interpreter { 12 | /** 13 | * @constructor 14 | * @param {string} query 15 | */ 16 | constructor(query) { 17 | const rawQuery = this.rawQuery = query.trim().replace(/\s*;$/, '') 18 | 19 | if (Cache.has(rawQuery)) { 20 | this.builder = Cache.get(rawQuery).clone() 21 | } else { 22 | this.build() 23 | } 24 | } 25 | 26 | /** 27 | * Resolve and then build the query. 28 | */ 29 | build() { 30 | this.resolvedQuery = this.resolveQuery(parseParentheses(this.rawQuery)) 31 | 32 | this.builder = buildQuery(this.resolvedQuery) 33 | 34 | // Add built query to cache, to avoid rebuilding the same query over and over. 35 | Cache.set(this.rawQuery, this.builder) 36 | } 37 | 38 | /** 39 | * Resolve the query array recursively and insert Methods. 40 | * 41 | * @param array $query 42 | * @return array 43 | * @throws InterpreterException 44 | */ 45 | resolveQuery(query) { 46 | // Using for, since the array will be altered. Foreach would change behaviour. 47 | for (let i = 0; i < query.length; i++) { 48 | let item = query[i] 49 | 50 | if (typeof item === 'string') { 51 | // Remove commas and remove item if empty. 52 | item = query[i] = item.replace(/,/g, ' ') 53 | 54 | if (item === '') { 55 | continue 56 | } 57 | 58 | try { 59 | // A string can be interpreted as a method. Let's try resolving the method then. 60 | const method = methodMatch(item.trim()) 61 | 62 | // If anything was left over (for example parameters), grab them and insert them. 63 | const leftOver = item.replace(new RegExp(method.origin, 'i'), '') 64 | query[i] = method 65 | if (leftOver !== '') { 66 | query.splice(i + 1, 0, leftOver.trim()) 67 | } 68 | } catch (e) { 69 | // There could be some parameters, so we'll split them and try to parse them again 70 | const matches = item.match(/(.*?)[\s]+(.*)/) 71 | 72 | if (matches) { 73 | query[i] = matches[1].trim() 74 | 75 | if (matches[2]) { 76 | query.splice(i + 1, 0, matches[2].trim()) 77 | } 78 | } 79 | } 80 | } else if (Array.isArray(item)) { 81 | query[i] = this.resolveQuery(item) 82 | } else if (!item instanceof Literally) { 83 | throw new InterpreterException(`Unexpected statement: ${JSON.stringify(item)}`) 84 | } 85 | } 86 | 87 | return query.filter((item) => item !== '') 88 | } 89 | 90 | /** 91 | * Return the built RegExp object. 92 | * 93 | * @return {RegExp} 94 | */ 95 | get() { 96 | return this.builder.get() 97 | } 98 | } 99 | 100 | module.exports = Interpreter 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SRL-JavaScript 2 | 3 | JavaScript implementation of [Simple Regex](https://simple-regex.com/) :tada::tada::tada: 4 | 5 | [![npm version](https://badge.fury.io/js/srl.svg)](https://badge.fury.io/js/srl) 6 | [![Build Status](https://travis-ci.org/SimpleRegex/SRL-JavaScript.svg?branch=master)](https://travis-ci.org/SimpleRegex/SRL-JavaScript) 7 | [![codecov](https://codecov.io/gh/SimpleRegex/SRL-JavaScript/branch/master/graph/badge.svg)](https://codecov.io/gh/SimpleRegex/SRL-JavaScript) 8 | 9 | > Because of the JavaScript regex engine, there is something different from [Simple Regex](https://simple-regex.com/) 10 | - Support `as` to assign capture name with CODE but not regex engine. 11 | - NOT support `if already had/if not already had` 12 | - NO `first match` and NO `all lazy`, since in JavaScript `lazy` means non-greedy (matching the fewest possible characters). 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install srl 18 | ``` 19 | 20 | ## Usage 21 | 22 | Class SRL accepts a Simple Regex Language string as input, and return the builder for the query. 23 | 24 | The builder can agent `test/exec` method to the generated regex object. Or you can use `get()` to take the generated regex object. 25 | 26 | ```js 27 | const SRL = require('srl') 28 | const query = new SRL('letter exactly 3 times') 29 | 30 | query.isMatching('aaa') // true 31 | query.getMatch('aaa') // [ 'aaa', index: 0, input: 'aaa' ] 32 | 33 | query 34 | .digit() 35 | .neverOrMore() 36 | .mustEnd() 37 | .get() // /[a-z]{3}[0-9]*$/g 38 | ``` 39 | 40 | Required Node 8.0+ for the ES6 support, Or you can use [Babel](http://babeljs.io/) to support Node below 6.0. 41 | 42 | Using [Webpack](http://webpack.github.io) and [babel-loader](https://github.com/babel/babel-loader) to pack it if want to use in browsers. 43 | 44 | ## Additional 45 | 46 | In SRL-JavaScript we apply `g` flag as default to follow the [Simple Regex](https://simple-regex.com/) "standard", so we provide more API to use regex conveniently. 47 | 48 | - `isMatching` - Validate if the expression matches the given string. 49 | 50 | ```js 51 | const query = new SRL('starts with letter twice') 52 | query.isMatching(' aa') // false 53 | query.isMatching('bbb') // true 54 | ``` 55 | 56 | - `getMatch` - Get first match of the given string, like run `regex.exec` once. 57 | 58 | ```js 59 | const query = new SRL('capture (letter twice) as word whitespace') 60 | 61 | query.getMatch('aa bb cc dd') // [ 'aa ', 'aa', index: 0, input: 'aa bb cc dd', word: 'aa' ] 62 | ``` 63 | 64 | - `getMatches` - Get all matches of the given string, like a loop to run `regex.exec`. 65 | 66 | ```js 67 | const query = new SRL('capture (letter twice) as word whitespace') 68 | 69 | query.getMatches('aa bb cc dd') 70 | /** 71 | * [ 72 | * [ 'aa ', 'aa', index: 0, input: 'aa bb cc dd', word: 'aa' ], 73 | * [ 'bb ', 'bb', index: 3, input: 'aa bb cc dd', word: 'bb' ], 74 | * [ 'cc ', 'cc', index: 6, input: 'aa bb cc dd', word: 'cc' ] 75 | * ] 76 | */ 77 | ``` 78 | 79 | - `removeModifier` - Remove specific flag. 80 | 81 | ```js 82 | const query = new SRL('capture (letter twice) as word whitespace') 83 | 84 | query.removeModifier('g') 85 | query.get() // /([a-z]{2})\s/ 86 | ``` 87 | 88 | ## Development 89 | 90 | First, clone repo and init submodule for test. 91 | 92 | SRL-JavaScript depends on [Mocha](http://mochajs.org) and [Istanbul](https://github.com/gotwarlost/istanbul) to test code. You can use them like this: 93 | 94 | ```sh 95 | npm install 96 | 97 | npm test # test 98 | npm run coverage # Get coverage locally 99 | ``` 100 | 101 | How to write Rules, see: [Test-Rules](https://github.com/SimpleRegex/Test-Rules). 102 | 103 | ## License 104 | 105 | SRL-JavaScript is published under the MIT license. See LICENSE for more information. 106 | -------------------------------------------------------------------------------- /test/interpreter-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const Interpreter = require('../lib/Language/Interpreter') 5 | 6 | describe('Interpreter isMatching', () => { 7 | it('Parser', () => { 8 | let query = new Interpreter('aNy Character ONCE or more literAlly "fO/o"') 9 | assert.deepEqual(query.get(), /\w+(?:fO\/o)/g) 10 | 11 | query = new Interpreter(` 12 | begin with literally "http", optional "s", literally "://", optional "www.", 13 | anything once or more, literally ".com", must end 14 | `) 15 | assert.deepEqual(query.get(), /^(?:http)(?:(?:s))?(?::\/\/)(?:(?:www\.))?.+(?:\.com)$/g) 16 | assert.ok(query.builder.isMatching('http://www.ebay.com')) 17 | assert.ok(query.builder.isMatching('https://google.com')) 18 | assert.ok(!query.builder.isMatching('htt://google.com')) 19 | assert.ok(!query.builder.isMatching('http://.com')) 20 | 21 | query = new Interpreter( 22 | 'begin with capture (digit from 0 to 8 once or more) if followed by "foo"' 23 | ) 24 | assert.deepEqual(query.get(), /^([0-8]+)(?=(?:foo))/g) 25 | assert.ok(query.builder.isMatching('142foo')) 26 | assert.ok(!query.builder.isMatching('149foo')) 27 | assert.ok(!query.builder.isMatching('14bar')) 28 | assert.equal(query.builder.getMatch('142foo')[1], '142') 29 | 30 | query = new Interpreter('literally "colo", optional "u", literally "r"') 31 | assert.ok(query.builder.isMatching('color')) 32 | assert.ok(query.builder.isMatching('colour')) 33 | 34 | query = new Interpreter( 35 | 'starts with number from 0 to 5 between 3 and 5 times, must end' 36 | ) 37 | assert.ok(query.builder.isMatching('015')) 38 | assert.ok(query.builder.isMatching('44444')) 39 | assert.ok(!query.builder.isMatching('444444')) 40 | assert.ok(!query.builder.isMatching('1')) 41 | assert.ok(!query.builder.isMatching('563')) 42 | 43 | query = new Interpreter( 44 | 'starts with digit exactly 2 times, letter at least 3 time' 45 | ) 46 | assert.deepEqual(query.get(), /^[0-9]{2}[a-z]{3,}/g) 47 | assert.ok(query.builder.isMatching('12abc')) 48 | assert.ok(query.builder.isMatching('12abcd')) 49 | assert.ok(!query.builder.isMatching('123abc')) 50 | assert.ok(!query.builder.isMatching('1a')) 51 | assert.ok(!query.builder.isMatching('')) 52 | }) 53 | 54 | it('Email', () => { 55 | const query = new Interpreter(` 56 | begin with any of (digit, letter, one of "._%+-") once or more, 57 | literally "@", either of (digit, letter, one of ".-") once or more, literally ".", 58 | letter at least 2, must end, case insensitive 59 | `) 60 | 61 | assert.ok(query.builder.isMatching('sample@example.com')) 62 | assert.ok(query.builder.isMatching('super-He4vy.add+ress@top-Le.ve1.domains')) 63 | assert.ok(!query.builder.isMatching('sample.example.com')) 64 | assert.ok(!query.builder.isMatching('missing@tld')) 65 | assert.ok(!query.builder.isMatching('hav ing@spac.es')) 66 | assert.ok(!query.builder.isMatching('no@pe.123')) 67 | assert.ok(!query.builder.isMatching('invalid@email.com123')) 68 | }) 69 | 70 | it('Capture Group', () => { 71 | const query = new Interpreter( 72 | 'literally "color:", whitespace, capture (letter once or more), literally ".", all' 73 | ) 74 | 75 | const target = 'Favorite color: green. Another color: yellow.' 76 | const matches = [] 77 | let result = null 78 | while (result = query.builder.exec(target)) { 79 | matches.push(result[1]) 80 | } 81 | 82 | assert.equal('green', matches[0]) 83 | assert.equal('yellow', matches[1]) 84 | }) 85 | 86 | it('Parentheses', () => { 87 | let query = new Interpreter( 88 | 'begin with (literally "foo", literally "bar") twice must end' 89 | ) 90 | assert.deepEqual(query.get(), /^(?:(?:foo)(?:bar)){2}$/g) 91 | assert.ok(query.builder.isMatching('foobarfoobar')) 92 | assert.ok(!query.builder.isMatching('foobar')) 93 | 94 | query = new Interpreter( 95 | 'begin with literally "bar", (literally "foo", literally "bar") twice must end' 96 | ) 97 | assert.deepEqual(query.get(), /^(?:bar)(?:(?:foo)(?:bar)){2}$/g) 98 | assert.ok(query.builder.isMatching('barfoobarfoobar')) 99 | 100 | query = new Interpreter('(literally "foo") twice') 101 | assert.deepEqual(query.get(), /(?:(?:foo)){2}/g) 102 | assert.ok(query.builder.isMatching('foofoo')) 103 | assert.ok(!query.builder.isMatching('foo')) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /test/rules-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const assert = require('assert') 6 | const SRL = require('../') 7 | 8 | function testRules() { 9 | const rulesDir = path.resolve(__dirname, './rules') 10 | const files = fs.readdirSync(rulesDir) 11 | 12 | files.forEach((file) => { 13 | // Ignore 14 | if (path.extname(file) !== '.rule') { 15 | return 16 | } 17 | 18 | it(file.slice(0, -5).split('_').join(' '), () => { 19 | const lines = fs.readFileSync(path.join(rulesDir, file), { encoding: 'utf-8' }).split('\n') 20 | runAssertions(buildData(lines)) 21 | }) 22 | }) 23 | } 24 | 25 | function applySpecialChars(target) { 26 | return target.replace('\\n', '\n').replace('\\t', '\t') 27 | } 28 | 29 | function getExpression(srl, query) { 30 | return `\n\nSupplied SRL Query: ${srl}\nGenerated Expression: ${query.getRawRegex()}\n\n` 31 | } 32 | 33 | function buildData(lines) { 34 | const data = { 35 | srl: null, 36 | matches: [], 37 | no_matches: [], 38 | captures: {} 39 | } 40 | let inCapture = false 41 | 42 | lines.forEach((line) => { 43 | if (line === '' || line.startsWith('#')) { 44 | return 45 | } 46 | 47 | if (inCapture && !line.startsWith('-')) { 48 | inCapture = false 49 | } 50 | 51 | if (line.startsWith('srl: ')) { 52 | data.srl = line.substr(5) 53 | } else if (line.startsWith('match: "')) { 54 | data.matches.push(applySpecialChars(line.slice(8, -1))) 55 | } else if (line.startsWith('no match: "')) { 56 | data.no_matches.push(applySpecialChars(line.slice(11, -1))) 57 | } else if ( 58 | line.startsWith('capture for "') && 59 | line.substr(-2, 2) === '":' 60 | ) { 61 | inCapture = line.slice(13, -2) 62 | data.captures[inCapture] = [] 63 | } else if (inCapture && line.startsWith('-')) { 64 | const split = line.substr(1).split(': ') 65 | let target = data.captures[inCapture][Number(split[0])] 66 | 67 | if (!target) { 68 | target = data.captures[inCapture][Number(split[0])] = [] 69 | } 70 | 71 | target[split[1]] = applySpecialChars(split[2].slice(1, -1)) 72 | } 73 | }) 74 | 75 | return data 76 | } 77 | 78 | function runAssertions(data) { 79 | assert(data.srl, 'SRL for rule is empty. Invalid rule.') 80 | 81 | let query, assertionMade = false 82 | 83 | try { 84 | query = new SRL(data.srl) 85 | } catch (e) { 86 | assert(false, `Parser error: ${e.message}\n\nSupplied SRL Query: ${data.srl}\n\n`) 87 | } 88 | 89 | data.matches.forEach((match) => { 90 | assert( 91 | query.isMatching(match), 92 | `Failed asserting that this query matches '${match}'.${getExpression(data.srl, query)}` 93 | ) 94 | assertionMade = true 95 | }) 96 | 97 | data.no_matches.forEach((noMatch) => { 98 | assert( 99 | !query.isMatching(noMatch), 100 | `Failed asserting that this query does not match '${noMatch}'.${getExpression(data.srl, query)}` 101 | ) 102 | assertionMade = true 103 | }) 104 | 105 | Object.keys(data.captures).forEach((test) => { 106 | const expected = data.captures[test] 107 | let matches = null 108 | 109 | try { 110 | matches = query.getMatches(test) 111 | } catch (e) { 112 | assert(false, `Parser error: ${e.message}${getExpression(data.srl, query)}`) 113 | } 114 | 115 | assert.equal( 116 | matches.length, 117 | expected.length, 118 | `Invalid match count for test ${test}.${getExpression(data.srl, query)}` 119 | ) 120 | 121 | matches.forEach((capture, index) => { 122 | // const result = Array.from(capture).slice(1).map((item) => { 123 | // return item === undefined ? '' : item 124 | // }) 125 | const item = expected[index] 126 | 127 | for (const key in item) { 128 | if (typeof key === 'number') { 129 | assert.equal( 130 | capture[key + 1], 131 | item[key], 132 | `The capture group did not return the expected results for test ${test}.${getExpression(data.srl, query)}` 133 | ) 134 | } else { 135 | assert.equal( 136 | capture[key], 137 | item[key], 138 | `The capture group did not return the expected results for test ${test}.${getExpression(data.srl, query)}` 139 | ) 140 | } 141 | } 142 | }) 143 | 144 | assertionMade = true 145 | }) 146 | 147 | assert(assertionMade, `No assertion. Invalid rule. ${getExpression(data.srl, query)}`) 148 | } 149 | 150 | describe('Rules', () => { 151 | testRules() 152 | }) 153 | -------------------------------------------------------------------------------- /lib/Language/Helpers/methodMatch.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const buildQuery = require('./buildQuery') 4 | const DefaultMethod = require('../Methods/Method') 5 | const SimpleMethod = require('../Methods/SimpleMethod') 6 | const ToMethod = require('../Methods/ToMethod') 7 | const TimesMethod = require('../Methods/TimesMethod') 8 | const AndMethod = require('../Methods/AndMethod') 9 | const AsMethod = require('../Methods/AsMethod') 10 | 11 | const SyntaxException = require('../../Exceptions/Syntax') 12 | 13 | // Unimplemented: all lazy, single line, unicode, first match 14 | const mapper = { 15 | 'any character': { 'class': SimpleMethod, 'method': 'anyCharacter' }, 16 | 'backslash': { 'class': SimpleMethod, 'method': 'backslash' }, 17 | 'no character': { 'class': SimpleMethod, 'method': 'noCharacter' }, 18 | 'multi line': { 'class': SimpleMethod, 'method': 'multiLine' }, 19 | 'case insensitive': { 'class': SimpleMethod, 'method': 'caseInsensitive' }, 20 | 'starts with': { 'class': SimpleMethod, 'method': 'startsWith' }, 21 | 'start with': { 'class': SimpleMethod, 'method': 'startsWith' }, 22 | 'begin with': { 'class': SimpleMethod, 'method': 'startsWith' }, 23 | 'begins with': { 'class': SimpleMethod, 'method': 'startsWith' }, 24 | 'must end': { 'class': SimpleMethod, 'method': 'mustEnd' }, 25 | 'once or more': { 'class': SimpleMethod, 'method': 'onceOrMore' }, 26 | 'never or more': { 'class': SimpleMethod, 'method': 'neverOrMore' }, 27 | 'new line': { 'class': SimpleMethod, 'method': 'newLine' }, 28 | 'whitespace': { 'class': SimpleMethod, 'method': 'whitespace' }, 29 | 'no whitespace': { 'class': SimpleMethod, 'method': 'noWhitespace' }, 30 | 'anything': { 'class': SimpleMethod, 'method': 'any' }, 31 | 'tab': { 'class': SimpleMethod, 'method': 'tab' }, 32 | 'vertical tab': { 'class': SimpleMethod, 'method': 'verticalTab' }, 33 | 'digit': { 'class': SimpleMethod, 'method': 'digit' }, 34 | 'no digit': { 'class': SimpleMethod, 'method': 'noDigit' }, 35 | 'nondigit': { 'class': SimpleMethod, 'method': 'noDigit' }, 36 | 'number': { 'class': SimpleMethod, 'method': 'digit' }, 37 | 'letter': { 'class': SimpleMethod, 'method': 'letter' }, 38 | 'uppercase': { 'class': SimpleMethod, 'method': 'uppercaseLetter' }, 39 | 'once': { 'class': SimpleMethod, 'method': 'once' }, 40 | 'twice': { 'class': SimpleMethod, 'method': 'twice' }, 41 | 'word': { 'class': SimpleMethod, 'method': 'word' }, 42 | 'no word': { 'class': SimpleMethod, 'method': 'nonWord' }, 43 | 'nonword': { 'class': SimpleMethod, 'method': 'nonWord' }, 44 | 'carriage return': { 'class': SimpleMethod, 'method': 'carriageReturn' }, 45 | 'carriagereturn': { 'class': SimpleMethod, 'method': 'carriageReturn' }, 46 | 47 | 'literally': { 'class': DefaultMethod, 'method': 'literally' }, 48 | 'either of': { 'class': DefaultMethod, 'method': 'anyOf' }, 49 | 'any of': { 'class': DefaultMethod, 'method': 'anyOf' }, 50 | 'none of': { 'class': DefaultMethod, 'method': 'noneOf' }, 51 | 'if followed by': { 'class': DefaultMethod, 'method': 'ifFollowedBy' }, 52 | 'if not followed by': { 'class': DefaultMethod, 'method': 'ifNotFollowedBy' }, 53 | 'optional': { 'class': DefaultMethod, 'method': 'optional' }, 54 | 'until': { 'class': DefaultMethod, 'method': 'until' }, 55 | 'raw': { 'class': DefaultMethod, 'method': 'raw' }, 56 | 'one of': { 'class': DefaultMethod, 'method': 'oneOf' }, 57 | 58 | 'digit from': { 'class': ToMethod, 'method': 'digit' }, 59 | 'number from': { 'class': ToMethod, 'method': 'digit' }, 60 | 'letter from': { 'class': ToMethod, 'method': 'letter' }, 61 | 'uppercase letter from': { 'class': ToMethod, 'method': 'uppercaseLetter' }, 62 | 'exactly': { 'class': TimesMethod, 'method': 'exactly' }, 63 | 'at least': { 'class': TimesMethod, 'method': 'atLeast' }, 64 | 'between': { 'class': AndMethod, 'method': 'between' }, 65 | 'capture': { 'class': AsMethod, 'method': 'capture' } 66 | } 67 | 68 | /** 69 | * Match a string part to a method. Please note that the string must start with a method. 70 | * 71 | * @param {string} part 72 | * @throws {SyntaxException} If no method was found, a SyntaxException will be thrown. 73 | * @return {method} 74 | */ 75 | function methodMatch(part) { 76 | let maxMatch = null 77 | let maxMatchCount = 0 78 | 79 | // Go through each mapper and check if the name matches. Then, take the highest match to avoid matching 80 | // 'any', if 'any character' was given, and so on. 81 | Object.keys(mapper).forEach((key) => { 82 | const regex = new RegExp(`^(${key.replace(' ', ') (')})`, 'i') 83 | const matches = part.match(regex) 84 | 85 | const count = matches ? matches.length : 0 86 | 87 | if (count > maxMatchCount) { 88 | maxMatchCount = count 89 | maxMatch = key 90 | } 91 | }) 92 | 93 | if (maxMatch) { 94 | // We've got a match. Create the desired object and populate it. 95 | const item = mapper[maxMatch] 96 | return new item['class'](maxMatch, item.method, buildQuery) 97 | } 98 | 99 | throw new SyntaxException(`Invalid method: ${part}`) 100 | } 101 | 102 | module.exports = methodMatch 103 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | // Possible Errors 9 | "comma-dangle": [ 2, "never" ], 10 | "no-cond-assign": 0, 11 | "no-console": 1, 12 | "no-constant-condition": 1, 13 | "no-control-regex": 0, 14 | "no-debugger": 1, 15 | "no-dupe-args": 2, 16 | "no-dupe-keys": 2, 17 | "no-duplicate-case": 2, 18 | "no-empty": 1, 19 | "no-ex-assign": 1, 20 | "no-extra-boolean-cast": 1, 21 | "no-extra-parens": 0, 22 | "no-extra-semi": 1, 23 | "no-func-assign": 1, 24 | "no-inner-declarations": 1, 25 | "no-invalid-regexp": 2, 26 | "no-irregular-whitespace": 2, 27 | "no-negated-in-lhs": 2, 28 | "no-obj-calls": 1, 29 | "no-regex-spaces": 1, 30 | "no-sparse-arrays": 2, 31 | "no-unreachable": 2, 32 | "use-isnan": 2, 33 | "valid-jsdoc": 0, 34 | "valid-typeof": 2, 35 | 36 | // Best Practices 37 | "accessor-pairs": 0, 38 | "block-scoped-var": 1, 39 | "complexity": 0, 40 | "consitent-return": 0, 41 | "curly": 1, 42 | "default-case": 0, 43 | "dot-notation": [ 1, { "allowKeywords": false } ], 44 | "dot-location": [ 1, "property" ], 45 | "eqeqeq": [ 2, "smart" ], 46 | "guard-for-in": 0, 47 | "no-alert": 2, 48 | "no-caller": 2, 49 | "no-div-regex": 1, 50 | "no-else-return": 0, 51 | "no-eq-null": 1, 52 | "no-eval": 0, 53 | "no-extend-native": 2, 54 | "no-extra-bind": 2, 55 | "no-fallthrough": 1, 56 | "no-floating-decimal": 1, 57 | "no-implied-eval": 2, 58 | "no-iterator": 2, 59 | "no-labels": [ 1, { allowLoop: true } ], 60 | "no-lone-blocks": 2, 61 | "no-loop-func": 0, 62 | "no-multi-spaces": 2, 63 | "no-multi-str": 1, 64 | "no-native-reassign": 2, 65 | "no-new-func": 0, 66 | "no-new-wrappers": 1, 67 | "no-new": 1, 68 | "no-octal-escape": 1, 69 | "no-octal": 0, 70 | "no-param-reassign": 0, 71 | "no-process-env": 2, 72 | "no-proto": 2, 73 | "no-redeclare": 2, 74 | "no-return-assign": 1, 75 | "no-script-url": 2, 76 | "no-self-compare": 2, 77 | "no-sequences": 0, 78 | "no-throw-literal": 1, 79 | "no-unused-expressions": 1, 80 | "no-void": 2, 81 | "no-warning-comments": 0, 82 | "no-with": 2, 83 | "radix": 0, 84 | "vars-on-top": 0, 85 | "wrap-iife": 1, 86 | "yoda": [ 1, "never", { "exceptRange": true } ], 87 | 88 | // Variables 89 | "no-catch-shadow": 1, 90 | "no-delete-var": 2, 91 | "no-label-var": 2, 92 | "no-shadow-restricted-names": 1, 93 | "no-shadow": 0, 94 | "no-undef-init": 1, 95 | "no-undef": 2, 96 | "no-undefined": 0, 97 | "no-unused-vars": 1, 98 | "no-use-before-define": 0, 99 | 100 | // Code style 101 | "brace-style": [ 2, "1tbs", { "allowSingleLine": true } ], 102 | "camelcase": [ 1, { "properties": "never" } ], 103 | "comma-spacing": [ 2, { "before": false, "after": true } ], 104 | "comma-style": [ 2, "last" ], 105 | "consistent-this": 0, 106 | "eol-last": 1, 107 | "func-names": 0, 108 | "func-style": 0, 109 | "indent": [ 1, 4 ], 110 | "key-spacing": [ 1, { "beforeColon": false, "afterColon": true } ], 111 | "lines-around-comment": 0, 112 | "max-nested-callbacks": [1, 4], 113 | "new-cap": 0, 114 | "new-parens": 1, 115 | "newline-after-var": 0, 116 | "no-array-constructor": 1, 117 | "no-continue": 0, 118 | "no-inline-comments": 0, 119 | "no-lonely-if": 1, 120 | "no-mixed-spaces-and-tabs": 2, 121 | "no-multiple-empty-lines": 1, 122 | "no-nested-ternary": 1, 123 | "no-new-object": 1, 124 | "no-spaced-func": 1, 125 | "no-ternary": 0, 126 | "no-trailing-spaces": [ 1, { "skipBlankLines": true } ], 127 | "no-underscore-dangle": 0, 128 | "no-unneeded-ternary": 1, 129 | "object-curly-spacing": [ 1, "always" ], 130 | "one-var": 0, 131 | "operator-assignment": 0, 132 | "operator-linebreak": [ 1, "after" ], 133 | "padded-blocks": 0, 134 | "quote-props": 0, 135 | "quotes": [ 1, "single" ], 136 | "semi-spacing": 0, 137 | "semi": [ 1, "never" ], 138 | "sort-vars": 0, 139 | "keyword-spacing": 1, 140 | "space-before-blocks": [ 1, "always" ], 141 | "space-before-function-paren": [ 1, { "anonymous": "always", "named": "never" } ], 142 | "space-in-parens": [ 1, "never" ], 143 | "space-infix-ops": [ 1, { "int32Hint": false } ], 144 | "space-unary-ops": [ 1, { "words": true, "nonwords": false } ], 145 | "spaced-comment": 0, 146 | "wrap-regex": 0, 147 | 148 | // EMCAScript 6 149 | "generator-star-spacing": [ 1, "before" ], 150 | "no-var": 1, 151 | "object-shorthand": [ 1, "always" ], 152 | "prefer-const": 1, 153 | 154 | // Legacy 155 | "max-depth": [ 1, 5 ], 156 | "max-len": 0, 157 | "max-params": [ 1, 5 ], 158 | "max-statements": 0, 159 | "no-bitwise": 0, 160 | "no-plusplus": 0 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/Language/Helpers/parseParentheses.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const SyntaxException = require('../../Exceptions/Syntax') 4 | const Literally = require('./Literally') 5 | 6 | /** 7 | * Parse parentheses and return multidimensional array containing the structure of the input string. 8 | * It will parse ( and ) and supports nesting, escaping using backslash and strings using ' or ". 9 | * 10 | * @param {string} query 11 | * @return {array} 12 | * @throws {SyntaxException} 13 | */ 14 | function parseParentheses(query) { 15 | let openCount = 0 16 | let openPos = false 17 | let closePos = false 18 | let inString = false 19 | let backslash = false 20 | const stringPositions = [] 21 | const stringLength = query.length 22 | 23 | if (query[0] === '(' && query[stringLength - 1] === ')') { 24 | query = query.slice(1, -1) 25 | } 26 | 27 | loop: 28 | for (let i = 0; i < stringLength; i++) { 29 | const char = query[i] 30 | 31 | if (inString) { 32 | if ( 33 | char === inString && 34 | (query[i - 1] !== '\\' || (query[i - 1] === '\\' && query[i - 2] === '\\')) 35 | ) { 36 | // We're no more in the string. Either the ' or " was not escaped, or it was but the backslash 37 | // before was escaped as well. 38 | inString = false 39 | 40 | // Also, to create a "Literally" object later on, save the string end position. 41 | stringPositions[stringPositions.length - 1].end = i - 1 42 | } 43 | 44 | continue 45 | } 46 | 47 | if (backslash) { 48 | // Backslash was defined in the last char. Reset it and continue, since it only matches one character. 49 | backslash = false 50 | continue 51 | } 52 | 53 | switch (char) { 54 | case '\\': 55 | // Set the backslash flag. This will skip one character. 56 | backslash = true 57 | break 58 | case '"': 59 | case '\'': 60 | // Set the string flag. This will tell the parser to skip over this string. 61 | inString = char 62 | // Also, to create a "Literally" object later on, save the string start position. 63 | stringPositions.push({ start: i }) 64 | break 65 | case '(': 66 | // Opening parenthesis, increase the count and set the pointer if it's the first one. 67 | openCount++ 68 | if (openPos === false) { 69 | openPos = i 70 | } 71 | break 72 | case ')': 73 | // Closing parenthesis, remove count 74 | openCount-- 75 | if (openCount === 0) { 76 | // If this is the matching one, set the closing pointer and break the loop, since we don't 77 | // want to match any following pairs. Those will be taken care of in a later recursion step. 78 | closePos = i 79 | break loop 80 | } 81 | break 82 | } 83 | } 84 | 85 | if (openCount !== 0) { 86 | throw new SyntaxException('Non-matching parenthesis found.') 87 | } 88 | 89 | if (closePos === false) { 90 | // No parentheses found. Use end of string. 91 | openPos = closePos = stringLength 92 | } 93 | 94 | let result = createLiterallyObjects(query, openPos, stringPositions) 95 | 96 | if (openPos !== closePos) { 97 | // Parentheses found. 98 | // First part is definitely without parentheses, since we'll match the first pair. 99 | result = result.concat([ 100 | // This is the inner part of the parentheses pair. There may be some more nested pairs, so we'll check them. 101 | parseParentheses(query.substr(openPos + 1, closePos - openPos - 1)) 102 | // Last part of the string wasn't checked at all, so we'll have to re-check it. 103 | ], parseParentheses(query.substr(closePos + 1))) 104 | } 105 | 106 | return result.filter((item) => typeof item !== 'string' || item.length) 107 | } 108 | 109 | /** 110 | * Replace all "literal strings" with a Literally object to simplify parsing later on. 111 | * 112 | * @param {string} string 113 | * @param {number} openPos 114 | * @param {array} stringPositions 115 | * @return {array} 116 | * @throws {SyntaxException} 117 | */ 118 | function createLiterallyObjects(query, openPos, stringPositions) { 119 | const firstRaw = query.substr(0, openPos) 120 | const result = [firstRaw.trim()] 121 | let pointer = 0 122 | 123 | stringPositions.forEach((stringPosition) => { 124 | if (!stringPosition.end) { 125 | throw new SyntaxException('Invalid string ending found.') 126 | } 127 | 128 | if (stringPosition.end < firstRaw.length) { 129 | // At least one string exists in first part, create a new object. 130 | 131 | // Remove the last part, since this wasn't parsed. 132 | result.pop() 133 | 134 | // Add part between pointer and string occurrence. 135 | result.push(firstRaw.substr(pointer, stringPosition.start - pointer).trim()) 136 | 137 | // Add the string as object. 138 | result.push(new Literally(firstRaw.substr( 139 | stringPosition.start + 1, 140 | stringPosition.end - stringPosition.start 141 | ))) 142 | 143 | result.push(firstRaw.substr(stringPosition.end + 2).trim()) 144 | 145 | pointer = stringPosition.end + 2 146 | } 147 | }) 148 | 149 | return result 150 | } 151 | 152 | module.exports = parseParentheses 153 | -------------------------------------------------------------------------------- /test/builder-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const SRL = require('../') 5 | 6 | describe('Builder isMatching', () => { 7 | it('Simple Phone Number Format', () => { 8 | const regex = new SRL() 9 | .startsWith() 10 | .literally('+') 11 | .digit().between(1, 3) 12 | .literally(' ') 13 | .digit().between(3, 4) 14 | .literally('-') 15 | .digit().onceOrMore() 16 | .mustEnd() 17 | 18 | assert.ok(regex.isMatching('+49 123-45')) 19 | assert.ok(regex.isMatching('+492 1235-4')) 20 | assert.ok(!regex.isMatching('+49 123 45')) 21 | assert.ok(!regex.isMatching('49 123-45')) 22 | assert.ok(!regex.isMatching('a+49 123-45')) 23 | assert.ok(!regex.isMatching('+49 123-45b')) 24 | }) 25 | 26 | it('Simple Email Format', () => { 27 | const regex = new SRL() 28 | .startsWith() 29 | .anyOf((query) => { 30 | query.digit().letter().oneOf('._%+-') 31 | }) 32 | .onceOrMore() 33 | .literally('@') 34 | .anyOf((query) => { 35 | query.digit().letter().oneOf('.-') 36 | }) 37 | .onceOrMore() 38 | .literally('.') 39 | .letter().atLeast(2) 40 | .mustEnd() 41 | .caseInsensitive() 42 | 43 | assert.equal(regex.getMatch('sample@example.com')[0], 'sample@example.com') 44 | assert.equal(regex.getMatch('super-He4vy.add+ress@top-Le.ve1.domains')[0], 'super-He4vy.add+ress@top-Le.ve1.domains') 45 | assert.ok(!regex.isMatching('sample.example.com')) 46 | assert.ok(!regex.isMatching('missing@tld')) 47 | assert.ok(!regex.isMatching('hav ing@spac.es')) 48 | assert.ok(!regex.isMatching('no@pe.123')) 49 | assert.ok(!regex.isMatching('invalid@email.com123')) 50 | }) 51 | 52 | it('Capture Group', () => { 53 | const regex = new SRL() 54 | .literally('colo') 55 | .optional('u') 56 | .literally('r') 57 | .anyOf((query) => { 58 | query.literally(':').and((query) => { 59 | query.literally(' is') 60 | }) 61 | }) 62 | .whitespace() 63 | .capture((query) => { 64 | query.letter().onceOrMore() 65 | }) 66 | .literally('.') 67 | 68 | assert.ok(regex.isMatching('my favorite color: blue.')) 69 | assert.ok(regex.isMatching('my favorite colour is green.')) 70 | assert.ok(!regex.isMatching('my favorite colour is green!')) 71 | 72 | const testcase = 'my favorite colour is green. And my favorite color: yellow.' 73 | const matches = regex.getMatch(testcase) 74 | assert.equal(matches[1], 'green') 75 | }) 76 | 77 | it('More Methods', () => { 78 | const regex = new SRL() 79 | .noWhitespace() 80 | .literally('a') 81 | .ifFollowedBy((builder) => { 82 | return builder.noCharacter() 83 | }) 84 | .tab() 85 | .mustEnd() 86 | .multiLine() 87 | 88 | const target = ` 89 | ba\t 90 | aaabbb 91 | ` 92 | assert.ok(regex.isMatching(target)) 93 | 94 | const regex2 = new SRL() 95 | .startsWith() 96 | .literally('a') 97 | .newLine() 98 | .whitespace() 99 | .onceOrMore() 100 | .literally('b') 101 | .mustEnd() 102 | 103 | const target2 = `a 104 | b` 105 | assert.ok(regex2.isMatching(target2)) 106 | }) 107 | 108 | it('Replace', () => { 109 | const regex = new SRL() 110 | .capture((query) => { 111 | query.anyCharacter().onceOrMore() 112 | }) 113 | .whitespace() 114 | .capture((query) => { 115 | query.digit().onceOrMore() 116 | }) 117 | .literally(', ') 118 | .capture((query) => { 119 | query.digit().onceOrMore() 120 | }) 121 | .caseInsensitive() 122 | .get() 123 | 124 | assert.equal('April 15, 2003'.replace(regex, '$1 1, $3'), 'April 1, 2003') 125 | }) 126 | 127 | it('Lazyness', () => { 128 | const regex = new SRL() 129 | .capture((query) => { 130 | query.literally(',').twice() 131 | .whitespace().optional() 132 | .lazy() 133 | }) 134 | 135 | const matches = regex.getMatch(',, ') 136 | assert.equal(matches[1], ',,') 137 | assert.notEqual(matches[1], ',, ') 138 | 139 | const regex2 = new SRL() 140 | .literally(',') 141 | .atLeast(1) 142 | .lazy() 143 | 144 | const matches2 = regex2.getMatch(',,,,,') 145 | assert.equal(matches2[0], ',') 146 | assert.notEqual(matches2[0], ',,,,,') 147 | 148 | }) 149 | 150 | it('Global as Default', () => { 151 | const regex = new SRL() 152 | .literally('a') 153 | .get() 154 | 155 | let count = 0 156 | 'aaa'.replace(regex, () => count++) 157 | 158 | assert.equal(count, 3) 159 | }) 160 | 161 | it('Raw', () => { 162 | const regex = new SRL() 163 | .literally('foo') 164 | .raw('b[a-z]r') 165 | .raw(/\d+/) 166 | 167 | assert.ok(regex.isMatching('foobzr123')) 168 | assert.ok(regex.isMatching('foobar1')) 169 | assert.ok(!regex.isMatching('fooa')) 170 | assert.ok(!regex.isMatching('foobar')) 171 | }) 172 | 173 | it('Remove modifier', () => { 174 | const regex = new SRL() 175 | .literally('foo') 176 | .removeModifier('g') 177 | .get() 178 | 179 | assert.deepEqual(regex, /(?:foo)/) 180 | }) 181 | }) 182 | -------------------------------------------------------------------------------- /lib/Builder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const SyntaxException = require('./Exceptions/Syntax') 4 | const BuilderException = require('./Exceptions/Builder') 5 | const ImplementationException = require('./Exceptions/Implementation') 6 | 7 | const NON_LITERAL_CHARACTERS = '[\\^$.|?*+()/' 8 | const METHOD_TYPE_BEGIN = 0b00001 9 | const METHOD_TYPE_CHARACTER = 0b00010 10 | const METHOD_TYPE_GROUP = 0b00100 11 | const METHOD_TYPE_QUANTIFIER = 0b01000 12 | const METHOD_TYPE_ANCHOR = 0b10000 13 | const METHOD_TYPE_UNKNOWN = 0b11111 14 | const METHOD_TYPES_ALLOWED_FOR_CHARACTERS = METHOD_TYPE_BEGIN | METHOD_TYPE_ANCHOR | METHOD_TYPE_GROUP | METHOD_TYPE_QUANTIFIER | METHOD_TYPE_CHARACTER 15 | 16 | const simpleMapper = { 17 | 'startsWith': { 18 | 'add': '^', 19 | 'type': METHOD_TYPE_ANCHOR, 20 | 'allowed': METHOD_TYPE_BEGIN 21 | }, 22 | 'mustEnd': { 23 | 'add': '$', 24 | 'type': METHOD_TYPE_ANCHOR, 25 | 'allowed': METHOD_TYPE_CHARACTER | METHOD_TYPE_QUANTIFIER | METHOD_TYPE_GROUP 26 | }, 27 | 'onceOrMore': { 28 | 'add': '+', 29 | 'type': METHOD_TYPE_QUANTIFIER, 30 | 'allowed': METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP 31 | }, 32 | 'neverOrMore': { 33 | 'add': '*', 34 | 'type': METHOD_TYPE_QUANTIFIER, 35 | 'allowed': METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP 36 | }, 37 | 'any': { 38 | 'add': '.', 39 | 'type': METHOD_TYPE_CHARACTER, 40 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 41 | }, 42 | 'backslash': { 43 | 'add': '\\\\', 44 | 'type': METHOD_TYPE_CHARACTER, 45 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 46 | }, 47 | 'tab': { 48 | 'add': '\\t', 49 | 'type': METHOD_TYPE_CHARACTER, 50 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 51 | }, 52 | 'verticalTab': { 53 | 'add': '\\v', 54 | 'type': METHOD_TYPE_CHARACTER, 55 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 56 | }, 57 | 'newLine': { 58 | 'add': '\\n', 59 | 'type': METHOD_TYPE_CHARACTER, 60 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 61 | }, 62 | 'carriageReturn': { 63 | 'add': '\\r', 64 | 'type': METHOD_TYPE_CHARACTER, 65 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 66 | }, 67 | 'whitespace': { 68 | 'add': '\\s', 69 | 'type': METHOD_TYPE_CHARACTER, 70 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 71 | }, 72 | 'noWhitespace': { 73 | 'add': '\\S', 74 | 'type': METHOD_TYPE_CHARACTER, 75 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 76 | }, 77 | 'anyCharacter': { 78 | 'add': '\\w', 79 | 'type': METHOD_TYPE_CHARACTER, 80 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 81 | }, 82 | 'noCharacter': { 83 | 'add': '\\W', 84 | 'type': METHOD_TYPE_CHARACTER, 85 | 'allowed': METHOD_TYPES_ALLOWED_FOR_CHARACTERS 86 | }, 87 | 'word': { 88 | 'add': '\\b', 89 | 'type': METHOD_TYPE_CHARACTER, 90 | 'allowed': METHOD_TYPE_BEGIN 91 | }, 92 | 'nonWord': { 93 | 'add': '\\B', 94 | 'type': METHOD_TYPE_CHARACTER, 95 | 'allowed': METHOD_TYPE_BEGIN 96 | } 97 | 98 | } 99 | 100 | class Builder { 101 | /** 102 | * @constructor 103 | */ 104 | constructor() { 105 | /** @var {array} _regEx Regular Expression being built. */ 106 | this._regEx = [] 107 | 108 | /** @var {string} _modifiers Raw modifier to apply on. */ 109 | this._modifiers = 'g' 110 | 111 | /** @var {number} _lastMethodType Type of last method, to avoid invalid builds. */ 112 | this._lastMethodType = METHOD_TYPE_BEGIN 113 | 114 | /** @var {RegExp|null} _result Regular Expression Object built. */ 115 | this._result = null 116 | 117 | /** @var {string} _group Desired group, if any */ 118 | this._group = '%s' 119 | 120 | /** @var {string} _implodeString String to join with. */ 121 | this._implodeString = '' 122 | 123 | /** @var {array} _captureNames Save capture names to map */ 124 | this._captureNames = [] 125 | } 126 | 127 | /**********************************************************/ 128 | /* CHARACTERS */ 129 | /**********************************************************/ 130 | 131 | /** 132 | * Add raw Regular Expression to current expression. 133 | * 134 | * @param {string|RegExp} regularExpression 135 | * @throws {BuilderException} 136 | * @return {Builder} 137 | */ 138 | raw(regularExpression) { 139 | regularExpression = regularExpression instanceof RegExp ? 140 | regularExpression.toString().slice(1, -1) : 141 | regularExpression 142 | 143 | this._lastMethodType = METHOD_TYPE_UNKNOWN 144 | this.add(regularExpression) 145 | 146 | if (!this._isValid()) { 147 | this._revertLast() 148 | throw new BuilderException('Adding raw would invalidate this regular expression. Reverted.') 149 | } 150 | 151 | return this 152 | } 153 | 154 | /** 155 | * Literally match one of these characters. 156 | * 157 | * @param {string} chars 158 | * @return {Builder} 159 | */ 160 | oneOf(chars) { 161 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 162 | 163 | let result = chars.split('').map((character) => this.escape(character)).join('') 164 | result = result.replace('-', '\\-').replace(']', '\\]') 165 | 166 | return this.add(`[${result}]`) 167 | } 168 | 169 | /** 170 | * Literally match a character that is not one of these characters. 171 | * 172 | * @param {string} chars 173 | * @return {Builder} 174 | */ 175 | noneOf(chars) { 176 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 177 | 178 | let result = chars.split('').map((character) => this.escape(character)).join('') 179 | result = result.replace('-', '\\-').replace(']', '\\]') 180 | 181 | return this.add(`[^${result}]`) 182 | } 183 | 184 | /** 185 | * Literally match all of these characters in that order. 186 | * 187 | * @param {string} chars One or more characters 188 | * @return {Builder} 189 | */ 190 | literally(chars) { 191 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 192 | const result = chars.split('').map((character) => this.escape(character)).join('') 193 | 194 | return this.add(`(?:${result})`) 195 | } 196 | 197 | /** 198 | * Match any digit (in given span). Default will be a digit between 0 and 9. 199 | * 200 | * @param {number} min 201 | * @param {number} max 202 | * @return {Builder} 203 | */ 204 | digit(min = 0, max = 9) { 205 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 206 | 207 | return this.add(`[${min}-${max}]`) 208 | } 209 | 210 | /** 211 | * Match any non-digit character (in given span). Default will be any character not between 0 and 9. 212 | * 213 | * @return {Builder} 214 | */ 215 | noDigit() { 216 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 217 | 218 | return this.add('[^0-9]') 219 | } 220 | 221 | /** 222 | * Match any uppercase letter (between A to Z). 223 | * 224 | * @param {string} min 225 | * @param {string} max 226 | * @return {Builder} 227 | */ 228 | uppercaseLetter(min = 'A', max = 'Z') { 229 | return this.add(`[${min}-${max}]`) 230 | } 231 | 232 | /** 233 | * Match any lowercase letter (bwteen a to z). 234 | * @param {string} min 235 | * @param {string} max 236 | * @return {Builder} 237 | */ 238 | letter(min = 'a', max = 'z') { 239 | this._validateAndAddMethodType(METHOD_TYPE_CHARACTER, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 240 | 241 | return this.add(`[${min}-${max}]`) 242 | } 243 | 244 | /**********************************************************/ 245 | /* GROUPS */ 246 | /**********************************************************/ 247 | 248 | /** 249 | * Match any of these conditions. 250 | * 251 | * @param {Closure|Builder|string} conditions Anonymous function with its Builder as first parameter. 252 | * @return {Builder} 253 | */ 254 | anyOf(conditions) { 255 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 256 | 257 | return this._addClosure(new Builder()._extends('(?:%s)', '|'), conditions) 258 | } 259 | 260 | /** 261 | * Match all of these conditions, but in a non capture group. 262 | * 263 | * @param {Closure|Builder|string} conditions Anonymous function with its Builder as a first parameter. 264 | * @return {Builder} 265 | */ 266 | group(conditions) { 267 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 268 | 269 | return this._addClosure(new Builder()._extends('(?:%s)'), conditions) 270 | } 271 | 272 | /** 273 | * Match all of these conditions, Basically reverts back to the default mode, if coming from anyOf, etc. 274 | * 275 | * @param {Closure|Builder|string} conditions 276 | * @return {Builder} 277 | */ 278 | and(conditions) { 279 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 280 | 281 | return this._addClosure(new Builder(), conditions) 282 | } 283 | 284 | /** 285 | * Positive lookahead. Match the previous condition only if followed by given conditions. 286 | * 287 | * @param {Closure|Builder|string} condition Anonymous function with its Builder as a first parameter. 288 | * @return {Builder} 289 | */ 290 | ifFollowedBy(conditions) { 291 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 292 | 293 | return this._addClosure(new Builder()._extends('(?=%s)'), conditions) 294 | } 295 | 296 | /** 297 | * Negative lookahead. Match the previous condition only if NOT followed by given conditions. 298 | * 299 | * @param {Closure|Builder|string} condition Anonymous function with its Builder as a first parameter. 300 | * @return {Builder} 301 | */ 302 | ifNotFollowedBy(conditions) { 303 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 304 | 305 | return this._addClosure(new Builder()._extends('(?!%s)'), conditions) 306 | } 307 | 308 | /** 309 | * Create capture group of given conditions. 310 | * 311 | * @param {Closure|Builder|string} condition Anonymous function with its Builder as a first parameter. 312 | * @param {String} name 313 | * @return {Builder} 314 | */ 315 | capture(conditions, name) { 316 | if (name) { 317 | this._captureNames.push(name) 318 | } 319 | 320 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 321 | 322 | return this._addClosure(new Builder()._extends('(%s)'), conditions) 323 | } 324 | 325 | /**********************************************************/ 326 | /* QUANTIFIERS */ 327 | /**********************************************************/ 328 | 329 | /** 330 | * Make the last or given condition optional. 331 | * 332 | * @param {null|Closure|Builder|string} conditions Anonymous function with its Builder as a first parameter. 333 | * @return {Builder} 334 | */ 335 | optional(conditions = null) { 336 | this._validateAndAddMethodType(METHOD_TYPE_QUANTIFIER, METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP) 337 | 338 | if (!conditions) { 339 | return this.add('?') 340 | } 341 | 342 | return this._addClosure(new Builder()._extends('(?:%s)?'), conditions) 343 | } 344 | 345 | /** 346 | * Previous match must occur so often. 347 | * 348 | * @param {number} min 349 | * @param {number} max 350 | * @return {Builder} 351 | */ 352 | between(min, max) { 353 | this._validateAndAddMethodType(METHOD_TYPE_QUANTIFIER, METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP) 354 | 355 | return this.add(`{${min},${max}}`) 356 | } 357 | 358 | /** 359 | * Previous match must occur at least this often. 360 | * 361 | * @param {number} min 362 | * @return {Builder} 363 | */ 364 | atLeast(min) { 365 | this._validateAndAddMethodType(METHOD_TYPE_QUANTIFIER, METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP) 366 | 367 | return this.add(`{${min},}`) 368 | } 369 | 370 | /** 371 | * Previous match must occur exactly once. 372 | * 373 | * @return {Builder} 374 | */ 375 | once() { 376 | return this.exactly(1) 377 | } 378 | 379 | /** 380 | * Previous match must occur exactly twice. 381 | * 382 | * @return {Builder} 383 | */ 384 | twice() { 385 | return this.exactly(2) 386 | } 387 | 388 | /** 389 | * Previous match must occur exactly this often. 390 | * 391 | * @param {number} count 392 | * @return {Builder} 393 | */ 394 | exactly(count) { 395 | this._validateAndAddMethodType(METHOD_TYPE_QUANTIFIER, METHOD_TYPE_CHARACTER | METHOD_TYPE_GROUP) 396 | 397 | return this.add(`{${count}}`) 398 | } 399 | 400 | /** 401 | * Match less chars instead of more (lazy). 402 | * 403 | * @return {Builder} 404 | * @throws {ImplementationException} 405 | */ 406 | lazy() { 407 | const chars = '+*}?' 408 | const raw = this.getRawRegex() 409 | const last = raw.substr(-1) 410 | const lastMethodType = this._lastMethodType 411 | this._lastMethodType = METHOD_TYPE_QUANTIFIER 412 | 413 | if (!chars.includes(last)) { 414 | if (last === ')' && chars.includes(raw.substr(-2, 1))) { 415 | const target = lastMethodType === METHOD_TYPE_GROUP ? this._revertLast().slice(0, -1) + '?)' : '?' 416 | return this.add(target) 417 | } 418 | 419 | throw new ImplementationException('Cannot apply laziness at this point. Only applicable after quantifier.') 420 | } 421 | 422 | return this.add('?') 423 | } 424 | 425 | /** 426 | * Match up to the given condition. 427 | * 428 | * @param {Closure|Builder|string} toCondition 429 | * @return {Builder} 430 | */ 431 | until(toCondition) { 432 | this.lazy() 433 | this._validateAndAddMethodType(METHOD_TYPE_GROUP, METHOD_TYPES_ALLOWED_FOR_CHARACTERS) 434 | 435 | return this._addClosure(new Builder(), toCondition) 436 | } 437 | 438 | /**********************************************************/ 439 | /* MODIFIER MAPPER */ 440 | /**********************************************************/ 441 | 442 | multiLine() { 443 | return this._addUniqueModifier('m') 444 | } 445 | 446 | caseInsensitive() { 447 | return this._addUniqueModifier('i') 448 | } 449 | 450 | // Todo 451 | // unicode() 452 | // sticky() 453 | 454 | /**********************************************************/ 455 | /* SIMPLE MAPPER */ 456 | /**********************************************************/ 457 | 458 | startsWith() { 459 | return this._addFromMapper('startsWith') 460 | } 461 | 462 | mustEnd() { 463 | return this._addFromMapper('mustEnd') 464 | } 465 | 466 | onceOrMore() { 467 | return this._addFromMapper('onceOrMore') 468 | } 469 | 470 | neverOrMore() { 471 | return this._addFromMapper('neverOrMore') 472 | } 473 | 474 | any() { 475 | return this._addFromMapper('any') 476 | } 477 | 478 | backslash() { 479 | return this._addFromMapper('backslash') 480 | } 481 | 482 | tab() { 483 | return this._addFromMapper('tab') 484 | } 485 | 486 | verticalTab() { 487 | return this._addFromMapper('verticalTab') 488 | } 489 | 490 | newLine() { 491 | return this._addFromMapper('newLine') 492 | } 493 | 494 | whitespace() { 495 | return this._addFromMapper('whitespace') 496 | } 497 | 498 | noWhitespace() { 499 | return this._addFromMapper('noWhitespace') 500 | } 501 | 502 | anyCharacter() { 503 | return this._addFromMapper('anyCharacter') 504 | } 505 | 506 | noCharacter() { 507 | return this._addFromMapper('noCharacter') 508 | } 509 | 510 | word() { 511 | return this._addFromMapper('word') 512 | } 513 | 514 | nonWord() { 515 | return this._addFromMapper('nonWord') 516 | } 517 | 518 | /**********************************************************/ 519 | /* INTERNAL METHODS */ 520 | /**********************************************************/ 521 | 522 | /** 523 | * Escape specific character. 524 | * 525 | * @param {string} character 526 | * @return {string} 527 | */ 528 | escape(character) { 529 | return (NON_LITERAL_CHARACTERS.includes(character) ? '\\' : '') + character 530 | } 531 | 532 | /** 533 | * Get the raw regular expression string. 534 | * 535 | * @return string 536 | */ 537 | getRawRegex() { 538 | return this._group.replace('%s', this._regEx.join(this._implodeString)) 539 | } 540 | 541 | /** 542 | * Get all set modifiers. 543 | * 544 | * @return {string} 545 | */ 546 | getModifiers() { 547 | return this._modifiers 548 | } 549 | 550 | /** 551 | * Add condition to the expression query. 552 | * 553 | * @param {string} condition 554 | * @return {Builder} 555 | */ 556 | add(condition) { 557 | this._result = null // Reset result to make up a new one. 558 | this._regEx.push(condition) 559 | return this 560 | } 561 | 562 | /** 563 | * Validate method call. This will throw an exception if the called method makes no sense at this point. 564 | * Will add the current type as the last method type. 565 | * 566 | * @param {number} type 567 | * @param {number} allowed 568 | * @param {string} methodName 569 | */ 570 | _validateAndAddMethodType(type, allowed, methodName) { 571 | if (allowed & this._lastMethodType) { 572 | this._lastMethodType = type 573 | return 574 | } 575 | 576 | const message = { 577 | [METHOD_TYPE_BEGIN]: 'at the beginning', 578 | [METHOD_TYPE_CHARACTER]: 'after a literal character', 579 | [METHOD_TYPE_GROUP]: 'after a group', 580 | [METHOD_TYPE_QUANTIFIER]: 'after a quantifier', 581 | [METHOD_TYPE_ANCHOR]: 'after an anchor' 582 | }[this._lastMethodType] 583 | 584 | throw new ImplementationException( 585 | `Method ${methodName} is not allowed ${message || 'here'}` 586 | ) 587 | } 588 | 589 | /** 590 | * Add the value form simple mapper to the regular expression. 591 | * 592 | * @param {string} name 593 | * @return {Builder} 594 | * @throws {BuilderException} 595 | */ 596 | _addFromMapper(name) { 597 | const item = simpleMapper[name] 598 | if (!item) { 599 | throw new BuilderException('Unknown mapper.') 600 | } 601 | 602 | this._validateAndAddMethodType(item.type, item.allowed, name) 603 | return this.add(item.add) 604 | } 605 | 606 | /** 607 | * Add a specific unique modifier. This will ignore all modifiers already set. 608 | * 609 | * @param {string} modifier 610 | * @return {Builder} 611 | */ 612 | _addUniqueModifier(modifier) { 613 | this._result = null 614 | 615 | if (!this._modifiers.includes(modifier)) { 616 | this._modifiers += modifier 617 | } 618 | 619 | return this 620 | } 621 | 622 | /** 623 | * Build the given Closure or string and append it to the current expression. 624 | * 625 | * @param {Builder} builder 626 | * @param {Closure|Builder|string} conditions Either a closure, literal character string or another Builder instance. 627 | */ 628 | _addClosure(builder, conditions) { 629 | if (typeof conditions === 'string') { 630 | builder.literally(conditions) 631 | } else if (conditions instanceof Builder) { 632 | builder.raw(conditions.getRawRegex()) 633 | } else { 634 | conditions(builder) 635 | } 636 | 637 | return this.add(builder.getRawRegex()) 638 | } 639 | 640 | /** 641 | * Get and remove last added element. 642 | * 643 | * @return {string} 644 | */ 645 | _revertLast() { 646 | return this._regEx.pop() 647 | } 648 | 649 | /** 650 | * Build and return the resulting RegExp object. This will apply all the modifiers. 651 | * 652 | * @return {RegExp} 653 | * @throws {SyntaxException} 654 | */ 655 | get() { 656 | if (this._isValid()) { 657 | return this._result 658 | } else { 659 | throw new SyntaxException('Generated expression seems to be invalid.') 660 | } 661 | } 662 | 663 | /** 664 | * Validate regular expression. 665 | * 666 | * @return {boolean} 667 | */ 668 | _isValid() { 669 | if (this._result) { 670 | return true 671 | } else { 672 | try { 673 | this._result = new RegExp(this.getRawRegex(), this.getModifiers()) 674 | return true 675 | } catch (e) { 676 | return false 677 | } 678 | } 679 | } 680 | 681 | /** 682 | * Extends self to match more cases. 683 | * 684 | * @param {string} group 685 | * @param {string} implodeString 686 | * @return {Builder} 687 | */ 688 | _extends(group, implodeString = '') { 689 | this._group = group 690 | this._implodeString = implodeString 691 | return this 692 | } 693 | 694 | /** 695 | * Clone a new builder object. 696 | * 697 | * @return {Builder} 698 | */ 699 | clone() { 700 | const clone = new Builder() 701 | 702 | // Copy deeply 703 | clone._regEx = Array.from(this._regEx) 704 | clone._modifiers = this._modifiers 705 | clone._lastMethodType = this._lastMethodType 706 | clone._group = this._group 707 | 708 | return clone 709 | } 710 | 711 | /** 712 | * Remote specific flag. 713 | * 714 | * @param {string} flag 715 | * @return {Builder} 716 | */ 717 | removeModifier(flag) { 718 | this._modifiers = this._modifiers.replace(flag, '') 719 | this._result = null 720 | 721 | return this 722 | } 723 | 724 | /**********************************************************/ 725 | /* REGEX METHODS */ 726 | /**********************************************************/ 727 | exec() { 728 | const regexp = this.get() 729 | return regexp.exec.apply(regexp, arguments) 730 | } 731 | 732 | test() { 733 | const regexp = this.get() 734 | return regexp.test.apply(regexp, arguments) 735 | } 736 | 737 | /**********************************************************/ 738 | /* ADDITIONAL METHODS */ 739 | /**********************************************************/ 740 | 741 | /** 742 | * Just like test in RegExp, but reset lastIndex. 743 | * 744 | * @param {string} target 745 | * @return {boolean} 746 | */ 747 | isMatching(target) { 748 | const result = this.test(target) 749 | this.get().lastIndex = 0 750 | return result 751 | } 752 | 753 | /** 754 | * Map capture index to name. 755 | * When `exec` give the result like: [ 'aa ', 'aa', index: 0, input: 'aa bb cc dd' ] 756 | * Then help to resolve to return: [ 'aa ', 'aa', index: 0, input: 'aa bb cc dd', [captureName]: 'aa' ] 757 | * 758 | * @param {object} result 759 | * @return {object} 760 | */ 761 | _mapCaptureIndexToName(result) { 762 | const names = this._captureNames 763 | 764 | // No match 765 | if (!result) {return null} 766 | 767 | return Array.prototype.reduce.call(result.slice(1), (result, current, index) => { 768 | if (names[index]) { 769 | result[names[index]] = current || '' 770 | } 771 | 772 | return result 773 | }, result) 774 | } 775 | 776 | /** 777 | * Just like match in String, but reset lastIndex. 778 | * 779 | * @param {string} target 780 | * @return {array|null} 781 | */ 782 | getMatch(target) { 783 | const regex = this.get() 784 | const result = regex.exec(target) 785 | regex.lastIndex = 0 786 | 787 | return this._mapCaptureIndexToName(result) 788 | } 789 | 790 | /** 791 | * Get all matches, just like loop for RegExp.exec. 792 | * @param {string} target 793 | */ 794 | getMatches(target) { 795 | const result = [] 796 | const regex = this.get() 797 | let temp = null 798 | 799 | while (temp = regex.exec(target)) { 800 | temp = this._mapCaptureIndexToName(temp) 801 | result.push(temp) 802 | } 803 | regex.lastIndex = 0 804 | 805 | return result 806 | } 807 | } 808 | 809 | module.exports = Builder 810 | --------------------------------------------------------------------------------