Skip to content

Latest commit

 

History

History
331 lines (272 loc) · 10.9 KB

File metadata and controls

331 lines (272 loc) · 10.9 KB

Element - cascading element tree

Class Element

debug = require('debug')
logger = debug('yang:element')
delegate = require('delegates')
Emitter = require('events').EventEmitter
Emitter.defaultMaxListeners = 100

kIndex = Symbol.for('element:index');
kCache = Symbol.for('element:cache');

class Element
  
  @property: (prop, desc) ->
    Object.defineProperty @prototype, prop, desc
    
  @use: ->
    res = [].concat(arguments...)
      .filter (x) -> x?
      .map (elem) =>
        exists = Element::match.call this, elem.kind, elem.tag
        if exists?
          @debug => "use: using previously loaded '#{elem.kind}:#{elem.tag}'"
          return exists
        try Element::merge.call this, elem
        catch e
          throw @error "use: unable to merge '#{elem.kind}:#{elem.tag}'", e
    return switch 
      when res.length > 1  then res
      when res.length is 1 then res[0]
      else undefined

  @logger: logger
  @debug: (f) -> switch
    when debug.enabled @logger.namespace then switch
      when typeof f is 'function' then @logger @uri, [].concat(f())...
      else @logger @uri, arguments...
  
  @error: (err, ctx) ->
    err = new Error err unless err instanceof Error
    err.uri = @uri
    err.src = this
    err.ctx = ctx
    return err

  logger: @logger
  debug: @debug
  error: @error

  constructor: (@kind, @tag, scope) ->
    unless @kind?
      throw @error "must supply 'kind' to create a new Element"

    @scope = scope if scope?
      
    Object.defineProperties this,
      parent: value: null, writable: true
      origin: value: null, writable: true
      state:  value: {}, writable: true
      [kIndex]: value: 0, writable: true
      emitter: value: new Emitter

  delegate @prototype, 'emitter'
    .method 'emit'
    .method 'once'
    .method 'on'
    .method 'off'

Computed Properties

  @property 'datakey',
    get: -> @tag ? @kind

  @property 'uri',
    get: -> switch
      when @parent instanceof Element
        mark = @kind
        mark += "(#{@tag})" if @tag? and @parent.scope?[@kind] in [ '0..n', '1..n', '*' ]
        "#{@parent.uri}/#{mark}"
      when @tag?
        "#{@kind}(#{@tag})"
      else
        @kind

  @property 'root',
    get: -> switch
      when @parent instanceof Element then @parent.root
      when @origin instanceof Element then @origin.root
      else this

  @property 'children',
    get: ->
      unless this[kCache]?
        elements = (v for own k, v of this when k not in [ 'parent', 'origin', 'tag', kCache, 'source' ])
          .reduce ((a,b) -> switch
            when b instanceof Element then a.concat b
            when b instanceof Array
              a.concat b.filter (x) -> x instanceof Element
            else a
          ), []
        this[kCache] = elements.sort (a,b) -> a[kIndex] - b[kIndex]
      return this[kCache];
        
  @property '*',  get: -> @children
  @property '..', get: -> @parent

Instance-level methods

clone

  clone: (opts={}) ->
    { origin = @origin, relative = true } = opts
    @debug => "cloning #{@kind}:#{@tag} with #{@children.length} elements"
    copy = (new @constructor @kind, @tag, @source).extends @children.map (x) -> x.clone opts
    copy.state = Object.create(@state)
    copy.state.relative = relative
    copy.origin = origin ? this
    return copy

extends (elements...)

This is the primary mechanism for defining sub-elements to become part of the element tree

  extends: ->
    elems = ([].concat arguments...).filter (x) -> x? and !!x
    return this unless elems.length > 0
    elems.forEach (expr) => @merge expr
    @emit 'change', elems...
    return this

merge (element)

This helper method merges a specific Element into current Element while performing @scope validations.

  merge: (elem, opts={}) ->
    unless elem instanceof Element
      throw @error "cannot merge invalid element into Element", elem
      
    elem.parent = this
    elem[kIndex] = @children.length if @children?
    this[kCache] = null 

    _merge = (item) ->
      if not item.node or opts.append or item.datakey not in (@keys ? [])
        @push item
        true
      else if opts.replace is true
        for x, i in this when x.datakey is item.datakey
          @splice i, 1, item
          break
        true
      else false

    unless @scope?
      unless @hasOwnProperty elem.kind
        @[elem.kind] = elem
        return elem

      unless Array.isArray @[elem.kind]
        exists = @[elem.kind]
        @[elem.kind] = [ exists ]
        Object.defineProperty @[elem.kind], 'keys',
          get: (-> @map (x) -> x.datakey ).bind @[elem.kind]
      unless _merge.call @[elem.kind], elem
        throw @error "constraint violation for '#{elem.kind} #{elem.datakey}' - cannot define more than once"

      return elem

    unless elem.kind of @scope
      if elem.scope? and (not elem.source.state.unbound and not @source.state.unbound)
        @debug => @scope
        throw @error "scope violation - invalid '#{elem.kind}' extension found"
      else
        @scope[elem.kind] = '*' # this is hackish...

    switch @scope[elem.kind]
      when '0..n', '1..n', '*'
        unless @hasOwnProperty elem.kind
          @[elem.kind] = []
          Object.defineProperty @[elem.kind], 'keys',
            get: (-> @map (x) -> x.datakey ).bind @[elem.kind]
        unless Array.isArray @[elem.kind]
          exists = @[elem.kind]
          @[elem.kind] = [ exists ]
          Object.defineProperty @[elem.kind], 'keys',
            get: (-> @map (x) -> x.datakey ).bind @[elem.kind]
        unless _merge.call @[elem.kind], elem
          throw @error "constraint violation for '#{elem.kind} #{elem.datakey}' - already defined"
      when '0..1', '1'
        unless @hasOwnProperty elem.kind
          @[elem.kind] = elem
        else if opts.replace is true
          @debug => "replacing pre-existing #{elem.kind}"
          @[elem.kind] = elem
        else
          throw @error "constraint violation for '#{elem.kind}' - cannot define more than once"
      else
        throw @error "unrecognized scope constraint defined for '#{elem.kind}' with #{@scope[elem.kind]}"

    return elem

  removes: ->
    elems = ([].concat arguments...).filter (x) -> x? and !!x
    return this unless elems.length > 0
    elems.forEach (expr) => @remove expr
    @emit 'change', elems...
    return this

  remove: (elem) ->
    unless elem instanceof Element
      throw @error "cannot remove a non-Element from an Element", elem

    exists = Element::match.call this, elem.kind, elem.datakey
    return this unless exists?

    if Array.isArray @[elem.kind]
      @[elem.kind] = @[elem.kind].filter (x) -> x.datakey isnt elem.datakey
      delete @[elem.kind] unless @[elem.kind].length
    else
      delete @[elem.kind]

    this[kCache] = null
    return this

update (element)

This alternative form of merge performs conditional merge based on existence check. It is considered safer alternative to direct merge call.

  # performs conditional merge based on existence
  update: (elem) ->
    unless elem instanceof Element
      throw @error "cannot update a non-Element into an Element", elem

    #@debug => "update with #{elem.kind}/#{elem.tag}"
    exists = switch
      when elem.tag? then Element::match.call this, elem.kind, elem.datakey
      else Element::match.call this, elem.kind
    return @merge elem unless exists?

    #@debug => "update #{exists.kind} in-place for #{elem.children.length} elements"
    exists.update target for target in elem.children
    return exists

  # Looks for matching Elements using kind and tag
  # Direction: up the hierarchy (towards root)
  lookup: (kind, tag) ->
    #@debug => "lookup: #{kind}(#{tag})..."
    res = switch
      when this not instanceof Object then undefined
      when this instanceof Element then @match kind, tag
      else Element::match.call this, kind, tag
    res ?= switch
      when @origin? then Element::lookup.apply @origin, arguments
      when @parent? then Element::lookup.apply @parent, arguments
      else Element::match.call @constructor, kind, tag
    #@debug => "lookup: #{kind}(#{tag}) got result: #{res?}"
    return res

  # Looks for matching Elements using YPATH notation
  # Direction: down the hierarchy (away from root)
  at: -> @locate arguments...
  locate: (ypath) ->
    return unless ypath?
    if typeof ypath is 'string'
      @debug => "locate: #{ypath}"
      ypath = ypath.replace /\s/g, ''
      if (/^\//.test ypath) and this isnt @root
        return @root.locate ypath
      [ key, rest... ] = ypath.split('/').filter (e) -> !!e
    else
      @debug => "locate: #{ypath.join('/')}"
      [ key, rest... ] = ypath
    return this unless key?

    match = switch
      when key is '..' then @match key
      else @match '*', key
        
    match ?= @match key if @scope[key] in ['0..1', '1']

    return switch
      when rest.length > 0 then match?.locate rest
      else match

  # Looks for a matching Element(s) in immediate sub-elements
  match: (kind, tag) ->
    return unless kind? and @[kind]?
    return @[kind] unless tag?

    match = @[kind]
    match = [ match ] unless match instanceof Array
    return match if tag is '*'

    for elem in match when elem instanceof Element
      return elem if tag is elem.datakey or tag is elem.tag
    return undefined

toJSON

Converts the Element into a JS object

  toJSON: (opts={ tag: true, extended: false }) ->
    #@debug => "converting #{@kind} toJSON with #{@children.length}"
    sub =
      @children
        .filter (x) => opts.extended or x.parent is this
        .reduce ((a,b) ->
          for k, v of b.toJSON()
            if a[k] instanceof Object
              a[k][kk] = vv for kk, vv of v if v instanceof Object
            else
              a[k] = v
          return a
        ), {}
    if opts.tag
      "#{@kind}": switch
        when Object.keys(sub).length > 0
          if @tag? then "#{@tag}": sub else sub
        when @tag instanceof Object then "#{@tag}"
        else @tag
    else sub

Export Element Class

module.exports = Element