pimatic
Version:
A home automation server and framework for the Raspberry PI running on node.js
751 lines (637 loc) • 22.2 kB
text/coffeescript
###
Matcher/Parser helper for predicate and action strings
=================
###
__ = require("i18n-pimatic").__
Promise = require 'bluebird'
S = require 'string'
assert = require 'cassert'
_ = require 'lodash'
milliseconds = require './milliseconds'
class Matcher
# Some static helper
comparators = {
'==': ['equals', 'is equal to', 'is equal', 'is']
'!=': [ 'is not', 'isnt' ]
'<': ['less', 'lower', 'below']
'>': ['greater', 'higher', 'above']
'>=': ['greater or equal', 'higher or equal', 'above or equal',
'equal or greater', 'equal or higher', 'equal or above']
'<=': ['less or equal', 'lower or equal', 'below or equal',
'equal or less', 'equal or lower', 'equal or below']
}
for sign in ['<', '>', '<=', '>=']
comparators[sign] = _(comparators[sign]).map(
(c) => [c, "is #{c}", "is #{c} than", "is #{c} as", "#{c} than", "#{c} as"]
).flatten().value()
for sign of comparators
comparators[sign].push(sign)
comparators['=='].push('=')
normalizeComparator = (comparator) ->
found = false
for sign, c of comparators
if comparator in c
comparator = sign
found = true
break
assert found
return comparator
# ###constructor()
# Create a matcher for the input string, with the given parse context.
constructor: (@input, @context = null, @prevInput = "", @elements = []) ->
# ###match()
###
Matches the current inputs against the given pattern.
Pattern can be a string, a regexp or an array of strings or regexps.
If a callback is given it is called with a new Matcher for the remaining part of the string
and the matching part of the input.
In addition a matcher is returned that has the remaining parts as input.
###
match: (patterns, options = {}, callback = null) ->
unless @input? then return @
unless Array.isArray patterns then patterns = [patterns]
if typeof options is "function"
callback = options
options = {}
matches = []
for p, j in patterns
# If pattern is an array then assume that first element is an ID that should be returned
# on match.
matchId = null
if Array.isArray p
assert p.length is 2
[matchId, p] = p
# Handle ignore case for string.
[pT, inputT] = (
if options.ignoreCase and typeof p is "string"
[p.toLowerCase(), @input.toLowerCase()]
else
[p, @input]
)
# If pattern is a string, then we can add an autocomplete for it.
if typeof p is "string" and @context
showAc = (if options.acFilter? then options.acFilter(p) else true)
if showAc
if S(pT).startsWith(inputT) and @input.length < p.length
@context?.addHint(autocomplete: p)
# Now try to match the pattern against the input string.
wildcardMatch = false
doesMatch = false
match = null
nextToken = null
if options.wildcard?
wildcardMatch = S(inputT).startsWith(options.wildcard)
switch
# Do a normal string match
when typeof p is "string"
doesMatch = S(inputT).startsWith(pT)
if doesMatch
match = p
nextToken = @input.substring(p.length)
# Do a regex match
when p instanceof RegExp
if options.ignoreCase?
throw new Error("ignoreCase option can't be used with regexp")
if options.wildcard?
throw new Error("wildcard option can't be used with regexp")
regexpMatch = @input.match(p)
if regexpMatch?
doesMatch = yes
match = regexpMatch[1]
nextToken = regexpMatch[2]
else throw new Error("Illegal object in patterns")
if wildcardMatch or doesMatch
if wildcardMatch
match = p
nextToken = @input.substring(options.wildcard.length)
assert match?
assert nextToken?
# If no matchID was provided then use the matching string itself.
unless matchId? then matchId = match
matches.push {
matchId
match
nextToken
}
if wildcardMatch then break
nextInput = null
match = null
prevInputAndMatch = ""
elements = []
if matches.length > 0
longestMatch = _(matches).sortBy( (m) => m.match.length ).last()
nextInput = longestMatch.nextToken
match = longestMatch.match
prevInputAndMatch = @prevInput + match
element = {
match: match
param: options.param
options: _.filter(
_.map(patterns, (p) => if Array.isArray p then p[1] else p),
(p) => p is match or (if options?.acFilter? then options.acFilter(p) else true)
)
type: options.type
wildcard: options.wildcard
wildcardMatch: wildcardMatch
}
if p instanceof RegExp
element.options = null
unless element.type?
element.type = "text"
else
unless element.type?
if element.options.length is 1
element.type = "static"
else
element.type = "select"
elements = @elements.concat element
if wildcardMatch and element.options?
element.options.unshift options.wildcard
if callback?
callback(
M(nextInput, @context, prevInputAndMatch, elements),
longestMatch.matchId
)
@context?.addElements(prevInputAndMatch, elements)
else if options.optional
nextInput = @input
prevInputAndMatch = @prevInput
elements = _.clone(@elements)
return M(nextInput, @context, prevInputAndMatch, elements)
# ###matchNumber()
###
Matches any number.
###
matchNumber: (options, callback) ->
unless @input? then return @
if typeof options is "function"
callback = options
options = {}
options.type = "number" unless options.type?
if options.wildcard? and S(@input).startsWith(options.wildcard)
return @match("0", options, callback)
next = @match /^(-?[0-9]+\.?[0-9]*)(.*?)$/, callback
showFormatHint = (@input is "" or next.input is "")
if showFormatHint
@context?.addHint(format: 'Number')
return next
matchVariable: (varsAndFuns, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables} = varsAndFuns
assert variables? and typeof variables is "object"
assert typeof callback is "function"
options = {
wildcard: "{variable}"
type: "select"
}
varsWithDollar = _(variables).keys().map( (v) => "$#{v}" ).valueOf()
matches = []
next = @match(varsWithDollar, options, (m, match) => matches.push([m, match]) )
if matches.length > 0
[next, match] = _(matches).sortBy( ([m, s]) => s.length ).last()
callback(next, match)
return next
matchString: (options, callback) ->
unless @input? then return @
if typeof options is "function"
callback = options
options = {}
options.type = "text" unless options.type
if options.wildcard? and S(@input).startsWith(options.wildcard)
return @match("\"\"", options, callback)
ret = M(null, @context)
@match('"').match(/^([^"]*)(.*?)$/, (m, str) =>
ret = m.match('"', (m) =>
callback(m, str)
)
)
return ret
matchOpenParenthese: (token, callback) ->
unless @input? then return @
tokens = []
openedParentheseMatch = yes
next = this
while openedParentheseMatch
m = next.match(token, (m) =>
tokens.push token
next = m.match(' ', optional: yes)
)
if m.hadNoMatch() then openedParentheseMatch = no
if tokens.length > 0
callback(next, tokens)
return next
matchCloseParenthese: (token, openedParentheseCount, callback) ->
unless @input? then return @
assert typeof openedParentheseCount is "number"
tokens = []
closeParentheseMatch = yes
next = this
while closeParentheseMatch and openedParentheseCount > 0
m = next.match(' ', optional: yes).match(token, (m) =>
tokens.push token
openedParentheseCount--
next = m
)
if m.hadNoMatch() then closeParentheseMatch = no
if tokens.length > 0
callback(next, tokens)
return next
matchFunctionCallArgs: (varsAndFuns, {funcName, argn}, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables, functions} = varsAndFuns
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
assert typeof callback is "function"
tokens = []
last = this
hint = yes
@matchAnyExpression(varsAndFuns, (next, ts) =>
tokens = tokens.concat ts
last = next
next
.match([',', ' , ', ' ,', ', '], {acFilter: (op) => op is ', '}, -> hint = false)
.matchFunctionCallArgs(varsAndFuns, {funcName, argn: argn+1}, (m, ts) =>
tokens.push ','
tokens = tokens.concat ts
last = m
)
)
if hint and last.input is ""
func = functions[funcName]
if func.args?
i = 0
for argName, arg of func.args
if arg.multiple?
if argn > i
@context?.addHint(format: argName)
break
if argn is i
if arg.optional
@context?.addHint(format: "[#{argName}]")
else
@context?.addHint(format: argName)
i++
callback(last, tokens)
return last
matchFunctionCall: (varsAndFuns, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables, functions} = varsAndFuns
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
assert typeof callback is "function"
tokens = []
last = null
@match(_.keys(functions), (next, funcName) =>
tokens.push funcName
next.match(['(', ' (', ' ( ', '( '], {acFilter: (op) => op is '('}, (next) =>
tokens.push '('
next.matchFunctionCallArgs(varsAndFuns, {funcName, argn: 0}, (next, ts) =>
tokens = tokens.concat ts
next.match([')', ' )'], {acFilter: (op) => op is ')'}, (next) =>
tokens.push ')'
last = next
)
)
)
)
if last?
callback(last, tokens)
return last
else return M(null, @context)
matchNumericExpression: (varsAndFuns, openParanteses = 0, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
openParanteses = 0
{variables, functions} = varsAndFuns
if typeof openParanteses is "function"
callback = openParanteses
openParanteses = 0
assert callback? and typeof callback is "function"
assert openParanteses? and typeof openParanteses is "number"
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
options = {
wildcard: "{expr}"
type: "text"
}
if options.wildcard? and S(@input).startsWith(options.wildcard)
return @match([[[0], "0"]], options, callback)
binarOps = ['+','-','*', '/']
binarOpsFull = _(binarOps).map( (op)=>[op, " #{op} ", " #{op}", "#{op} "] ).flatten().valueOf()
last = null
tokens = []
@matchOpenParenthese('(', (m, ptokens) =>
tokens = tokens.concat ptokens
openParanteses += ptokens.length
).or([
( (m) => m.matchNumber( (m, match) => tokens.push(parseFloat(match)); last = m ) ),
( (m) => m.matchVariable(varsAndFuns, (m, match) => tokens.push(match); last = m ) )
( (m) => m.matchFunctionCall(varsAndFuns, (m, match) =>
tokens = tokens.concat match
last = m
)
)
]).matchCloseParenthese(')', openParanteses, (m, ptokens) =>
tokens = tokens.concat ptokens
openParanteses -= ptokens.length
last = m
).match(binarOpsFull, {acFilter: (op) => op[0] is ' ' and op[op.length-1] is ' '}, (m, op) =>
m.matchNumericExpression(varsAndFuns, openParanteses, (m, nextTokens) =>
tokens.push(op.trim())
tokens = tokens.concat(nextTokens)
last = m
)
)
if last?
last.reduceElementsFrom(this, options)
callback(last, tokens)
return last
else return M(null, @context)
matchStringWithVars: (varsAndFuns, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables, functions} = varsAndFuns
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
assert typeof callback is "function"
options = {
wildcard: "{expr}"
type: "text"
}
if options.wildcard? and S(@input).startsWith(options.wildcard)
return @match([[["\"\""], "\"\""]], options, callback)
last = null
tokens = []
next = @match('"')
while next.hadMatch() and (not last?)
# match unescaped ", $ or {
next.match(/((?:(?:\\\\)*(?:\\.|[^"\$\{]))*)(.*?)$/, (m, strPart) =>
# strPart is string till first var or ending quote
strPart = strPart.replace(/(^|[^\\]|(\\\\)+)(\\n)/g, '$1$2\n') # make \n to new line
strPart = strPart.replace(/(^|[^\\]|(\\\\)+)(\\r)/g, '$1$2\r') # make \r to carriage return
strPart = strPart.replace(/\\(["\$\\\\{\\}])/g, '$1') # unescape ",/,$, { or }
tokens.push('"' + strPart + '"')
end = m.match('"')
if end.hadMatch()
last = end
# else test if it is a var
else
next = m.or([
( (m) => next = m.matchVariable(varsAndFuns, (m, match) => tokens.push(match) ); next ),
( (m) =>
retMatcher = M(null, @context)
m.match(['{', '{ '], {acFilter: (t)-> t is '{'}, (m, match) =>
m.matchAnyExpression(varsAndFuns, (m, ts) =>
m.match(['}', ' }'], {acFilter: (t)-> t is '}'}, (m) =>
tokens.push '('
tokens = tokens.concat ts
tokens.push ')'
retMatcher = m
)
)
)
return retMatcher
)
])
)
if last?
last.reduceElementsFrom(this, options)
callback(last, tokens)
return last
else return M(null, @context)
reduceElementsFrom: (matcher, options) ->
fullMatch = @getFullMatch()
@elements = matcher.elements.concat {
type: "text"
match: fullMatch.substring(matcher.getFullMatch().length)
wildcard: options.wildcard
}
@context?.addElements(fullMatch, @elements)
matchAnyExpression: (varsAndFuns, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables, functions} = varsAndFuns
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
assert typeof callback is "function"
tokens = null
next = @or([
( (m) => m.matchStringWithVars(varsAndFuns, (m, ts) => tokens = ts; return m) ),
( (m) => m.matchNumericExpression(varsAndFuns, (m, ts) => tokens = ts; return m) )
])
if tokens?
callback(next, tokens)
return next
matchComparator: (type, callback) ->
unless @input? then return @
assert type in ['number', 'string', 'boolean']
assert typeof callback is "function"
possibleComparators = (
switch type
when 'number' then _(comparators).values().flatten()
when 'string', 'boolean' then _(comparators['=='].concat comparators['!='])
).map((c)=>" #{c} ").value()
autocompleteFilter = (v) =>
v.trim() in ['is', 'is not', 'equals', 'is greater than', 'is less than',
'is greater or equal than', 'is less or equal than', '<', '=', '>', '<=', '>='
]
return @match(possibleComparators, acFilter: autocompleteFilter, ( (m, token) =>
comparator = normalizeComparator(token.trim())
return callback(m, comparator)
))
# ###matchDevice()
###
Matches any of the given devices.
###
matchDevice: (devices, callback = null) ->
unless @input? then return @
devicesWithId = _(devices).map( (d) => [d, d.id] ).value()
devicesWithNames = _(devices).map( (d) => [d, d.name] ).value()
matchingDevices = {}
onIdMatch = (m, d) =>
unless matchingDevices[d.id]?
matchingDevices[d.id] = {m, d}
else
# keep longest match
if d.id.length > d.name.length
matchingDevices[d.id].m = m
onNameMatch = (m, d) =>
unless matchingDevices[d.id]?
matchingDevices[d.id] = {m, d}
else
# keep longest match
if d.name.length > d.id.length
matchingDevices[d.id].m = m
next = @match('the ', optional: true, type: "static").or([
# first try to match by id
(m) => m.match(devicesWithId, wildcard: "{device}", type: "select", onIdMatch)
# then to try match names
(m) => m.match(
devicesWithNames,
wildcard: "{device}", type: "select", ignoreCase: yes,
onNameMatch)
])
for id, {m, d} of matchingDevices
callback(m, d)
return next
matchTimeDurationExpression: (varsAndFuns, callback) ->
unless @input? then return @
if typeof varsAndFuns is "function"
callback = varsAndFuns
varsAndFuns = @context
{variables, functions} = varsAndFuns
assert variables? and typeof variables is "object"
assert functions? and typeof functions is "object"
assert typeof callback is "function"
# Parse the for-Suffix:
timeUnits = [
"ms",
"second", "seconds", "s",
"minute", "minutes", "m",
"hour", "hours", "h",
"day", "days","d",
"year", "years", "y"
]
tokens = 0
unit = ""
onTimeExpressionMatch = (m, ts) => tokens = ts
onMatchUnit = (m, u) => unit = u.trim()
m = @matchNumericExpression(varsAndFuns, onTimeExpressionMatch).match(
_(timeUnits).map((u) => [" #{u}", u]).flatten().valueOf()
, {acFilter: (u) => u[0] is ' '}, onMatchUnit
)
if m.hadMatch()
callback(m, {tokens, unit})
return m
matchTimeDuration: (options = null, callback) ->
unless @input? then return @
if typeof options is 'function'
callback = options
options = {}
# Parse the for-Suffix:
timeUnits = [
"ms",
"second", "seconds", "s",
"minute", "minutes", "m",
"hour", "hours", "h",
"day", "days","d",
"year", "years", "y"
]
time = 0
unit = ""
onTimeMatch = (m, n) => time = parseFloat(n)
onMatchUnit = (m, u) => unit = u
m = @matchNumber(options, onTimeMatch).match(
_(timeUnits).map((u) => [" #{u}", u]).flatten().valueOf()
, {acFilter: (u) => u[0] is ' '}, onMatchUnit
)
if m.hadMatch()
timeMs = milliseconds.parse "#{time} #{unit}"
callback(m, {time, unit, timeMs})
return m
optional: (callback) ->
unless @input? then return @
next = callback(this)
if next.hadMatch()
return next
else
return this
# ###onEnd()
###
The given callback will be called for every empty string in the inputs of the current matcher.
###
onEnd: (callback) ->
if @input?.length is 0 then callback()
# ###onHadMatches()
###
The given callback will be called for every string in the inputs of the current matcher.
###
ifhadMatches: (callback) ->
if @input? then callback(@input)
###
m.inAnyOrder([
(m) => m.match(' title:').matchString(setTitle)
(m) => m.match(' message:').matchString(setMessage)
]).onEnd(...)
###
inAnyOrder: (callbacks) ->
assert Array.isArray callbacks
hadMatch = yes
current = this
while hadMatch
hadMatch = no
for next in callbacks
assert typeof next is "function"
# try to match with this matcher
m = next(current)
assert m instanceof Matcher
unless m.hadNoMatch()
hadMatch = yes
current = m
return current
or: (callbacks) ->
assert Array.isArray callbacks
matches = []
for cb in callbacks
m = cb(this)
assert m instanceof Matcher
matches.push m
# Get the longest match.
next = _.maxBy(matches, (m) =>
if m.input? then m.prevInput.length else 0
)
return next
hadNoMatch: -> not @input?
hadMatch: -> @input?
getFullMatch: -> unless @input? then null else @prevInput
getRemainingInput: -> @input
dump: (info) ->
console.log(info + ":") if info?
console.log "prevInput: \"#{@prevInput}\" "
console.log "input: \"#{@input}\""
return @
M = (args...) -> new Matcher(args...)
M.createParseContext = (variables, functions)->
return context = {
autocomplete: []
format: []
errors: []
warnings: []
elements: {}
variables,
functions
addHint: ({autocomplete: a, format: f}) ->
if a?
if Array.isArray a
@autocomplete = @autocomplete.concat a
else @autocomplete.push a
if f?
if Array.isArray f
@format = @format.concat f
else @format.push f
addError: (message) -> @errors.push message
addWarning: (message) -> @warnings.push message
hasErrors: -> (@errors.length > 0)
getErrorsAsString: -> _(@errors).reduce((ms, m) => "#{ms}, #{m}")
addElements: (input, elements) -> @elements[input] = elements
finalize: () ->
@autocomplete = _(@autocomplete).uniq().sortBy((s)=>s.toLowerCase()).value()
@format = _(@format).uniq().sortBy((s)=>s.toLowerCase()).value()
}
module.exports = M
module.exports.Matcher = Matcher