fbp-diff
Version:
Diff tool for FBP graphs
378 lines (311 loc) • 11.1 kB
text/coffeescript
clone = (obj) ->
return JSON.parse(JSON.stringify(obj))
jsonEquals = (a, b) ->
return JSON.stringify(a) == JSON.stringify(b)
processChanges = (from, to) ->
changes = []
fromNames = Object.keys from
toNames = Object.keys to
for name, process of from
if name not in toNames
changes.push
type: 'process-removed'
data:
name: name
process: clone process
for name, process of to
if name in fromNames
oldComponent = from[name].component
if process.component != oldComponent
changes.push
type: 'process-component-changed'
data:
component: process.component
name: name
process: process
previous:
component: oldComponent
# TODO: implement diffing of node metadata. Per top-level key?
else
changes.push
type: 'process-added'
data:
name: name
process: clone process
return changes
isIIP = (conn) ->
return conn.data?
isEdge = (conn) ->
return not isIIP(conn)
# NOTE: does not take metadata into account
connEquals = (a, b) ->
return a.process == b.process and a.port == b.port and a.index == b.index
edgeEquals = (a, b) ->
return connEquals(a.tgt, b.tgt) and connEquals(a.src, b.src)
iipEdgeEquals = (a, b) ->
return connEquals(a.tgt, b.tgt) and jsonEquals(a.data, b.data)
# TODO: distinguish between just the IIP payload changing, and just target of the IIP
iipChanges = (from, to) ->
changes = []
# IIP diffing
for edge in from
found = to.filter (e) -> return iipEdgeEquals edge, e
if found.length == 0
# removed
changes.push
type: 'iip-removed'
data: edge
else if found.length == 1
# FIXME: implement diffing of order changes for IIPs
else
throw new Error "Found duplicate matches for IIP: #{edge}\n #{found}"
for edge in to
found = from.filter (e) -> return iipEdgeEquals edge, e
if found.length == 0
# added
changes.push
type: 'iip-added'
data: edge
else if found.length == 1
# FIXME: implement diffing of order changes for IIPs
else
throw new Error "Found duplicate matches for IIP: #{edge}\n #{found}"
return changes
connectionChanges = (from, to) ->
# A connection can either be an edge (between two processes), or an IIP
fromEdges = from.filter isEdge
toEdges = to.filter isEdge
fromIIPs = from.filter isIIP
toIIPs = to.filter isIIP
changes = []
# Edge diffing
for edge in fromEdges
found = toEdges.filter (e) -> return edgeEquals edge, e
if found.length == 0
# removed
changes.push
type: 'edge-removed'
data: edge
else if found.length == 1
# FIXME: implement diffing of order changes for edges
else
throw new Error "Found duplicate matches for edge: #{edge}\n #{found}"
for edge in toEdges
found = fromEdges.filter (e) -> return edgeEquals edge, e
if found.length == 0
# added
changes.push
type: 'edge-added'
data: edge
else if found.length == 1
# FIXME: implement diffing of order changes for edges
else
throw new Error "Found duplicate matches for edge: #{edge}\n #{found}"
changes = changes.concat iipChanges(fromIIPs, toIIPs)
# TODO: implement diffing of connection metadata. Per top-level key?
return changes
# TODO: deduce when port was just renamed
portChanges = (from, to, kind) ->
throw new Error "Unsupported exported port kind: #{kind}" if not (kind in ['inport', 'outport'])
changes = []
fromNames = Object.keys from
toNames = Object.keys to
for name, target of from
existsNow = name in toNames
if not existsNow
# removed
changes.push
type: "exported-port-removed"
kind: kind
data:
name: name
target: target
for name, target of to
if name in fromNames
fromTarget = from[name]
if not connEquals(target, fromTarget)
changes.push
type: "exported-port-target-changed"
kind: kind
data:
name: name
target: target
previous:
target: fromTarget
else
# added
changes.push
type: "exported-port-added"
kind: kind
data:
name: name
target: target
return changes
removeByPredicate = (array, predicate) ->
removeIndices = []
for item, idx in array
removeIndices.push idx if predicate(item, idx, array)
removed = []
for item, idx in array
if idx in removeIndices
# removed, don't include
else
removed.push item
return removed
findRenamedExports = (changes, kind) ->
rewritten = changes
findTargets = (type) ->
c = changes.filter (c) -> c.type == type and c.kind == kind
t = c.map (c) -> return "#{c.process}.#{c.port}"
res =
changes: c
targets: t
return res
added = findTargets 'exported-port-added'
removed = findTargets 'exported-port-removed'
for target, targetIdx in removed.targets
addedIdx = added.targets.indexOf target
if addedIdx != -1
# both added and removed exported port, targeting the same node+port was -> a rename
a = added.changes[addedIdx]
r = removed.changes[targetIdx]
if not connEquals(a.data.target, r.data.target)
throw new Error "Sanity check failed, rename match did not have same target"
# rewrite changes
rewritten = removeByPredicate rewritten, (item) ->
exportedPort = (item.type == 'exported-port-added' or item.type == 'exported-port-removed')
targetEquals = connEquals a.data.target, item.data.target
return item.kind == kind and exportedPort and targetEquals
rewritten.push
type: 'exported-port-renamed'
kind: kind
data: a.data
previous:
name: r.data.name
return rewritten
applyHeuristics = (changes) ->
rewritten = clone changes
rewritten = findRenamedExports(rewritten, 'inport')
rewritten = findRenamedExports(rewritten, 'outport')
return rewritten
# calculate a list of changes between @from and @to
calculateDiff = (from, to) ->
# this is just the basics/dry-fact view. Any heuristics etc is applied afterwards
changes = []
# exported port changes
changes = changes.concat portChanges(from.inports, to.inports, 'inport')
changes = changes.concat portChanges(from.outports, to.outports, 'outport')
# nodes added/removed
changes = changes.concat processChanges(from.processes, to.processes)
# edges added/removed
changes = changes.concat connectionChanges(from.connections, to.connections)
# FIXME: diff graph properties
# TODO: support diffing of groups
diff =
raw: changes
changes: applyHeuristics changes
return diff
formatEdge = (e) ->
srcIndex = if e.src.index then "[#{e.src.index}]" else ""
tgtIndex = if e.tgt.index then "[#{e.tgt.index}]" else ""
return "#{e.src.process} #{e.src.port}#{srcIndex} -> #{e.tgt.port}#{tgtIndex} #{e.tgt.process}"
formatIIP = (e) ->
tgtIndex = if e.tgt.index then "[#{e.tgt.index}]" else ""
return "#{JSON.stringify(e.data)} -> #{e.tgt.port}#{tgtIndex} #{e.tgt.process}"
formatExport = (type, tgt, name) ->
return "#{type.toUpperCase()}=#{tgt.process}.#{tgt.port}:#{name}"
# TODO: include connection index for edges and IIPs?
formatChangeTextual = (change) ->
d = change.data
old = change.previous
switch change.type
when 'process-added' then "+ #{d.name}(#{d.process.component})"
when 'process-removed' then "- #{d.name}(#{d.process.component})"
when 'process-component-changed' then "$component #{d.name}(#{d.component}) was (#{old.component})"
when 'edge-added' then "+ #{formatEdge(d)}"
when 'edge-removed' then "- #{formatEdge(d)}"
when 'iip-added' then "+ #{formatIIP(d)}"
when 'iip-removed' then "- #{formatIIP(d)}"
when 'exported-port-added' then "+ #{formatExport(change.kind, d.target, d.name)}"
when 'exported-port-removed' then "- #{formatExport(change.kind, d.target, d.name)}"
when 'exported-port-target-changed' then ". #{formatExport(change.kind, d.target, d.name)} was #{formatExport(change.kind, old.target, d.name)}"
when 'exported-port-renamed' then ".rename #{formatExport(change.kind, d.target, d.name)} was #{formatExport(change.kind, d.target, old.name)}"
else
throw new Error "Cannot format unsupported change type: #{change.type}"
# TODO: group changes
formatDiffTextual = (diff, options) ->
lines = []
for change in diff.changes
lines.push formatChangeTextual change
return lines.join('\n')
# TODO: validate graph against schema
readGraph = (contents, type, options) ->
fbp = require 'fbp'
if type == 'fbp'
graph = fbp.parse contents, { caseSensitive: options.caseSensitive }
else if type == 'object'
graph = contents
else
graph = JSON.parse contents
# Normalize optional params
graph.inports = {} if not graph.inports?
graph.outports = {} if not graph.outports?
return graph
# TODO: support parsing up a diff from the textual output format?
# Mostly useful if/when one can apply diff as a patch
nullGraph = () ->
g =
processes: {}
connections: []
return clone g
# diff two graphs
exports.diff = (from, to, options) ->
options = normalizeOptions options
# Handle empty graph when in JSON format
f = if options.fromFormat == 'json' and from == ""
readGraph nullGraph(), 'object', options
else
readGraph from, options.fromFormat, options
t = if options.toFormat == 'json' and to == ""
readGraph nullGraph(), 'object', options
else
readGraph to, options.toFormat, options
diff = calculateDiff f, t
out = formatDiffTextual diff
return out
normalizeOptions = (options) ->
options = clone options
options.format = 'object' if not options.format?
options.fromFormat = options.format if not options.fromFormat?
options.toFormat = options.format if not options.toFormat?
options.caseSensitive = true if not options.caseSensitive?
return options
# node.js only
readGraphFile = (filepath, options, callback) ->
fs = require 'fs'
path = require 'path'
type = path.extname(filepath).replace('.', '')
fs.readFile filepath, { encoding: 'utf-8' }, (err, contents) ->
return callback err if err
try
graph = readGraph contents, type, options
catch e
return callback e
return callback null, graph
diffFiles = (fromPath, toPath, options, callback) ->
readGraphFile fromPath, options, (err, fromGraph) ->
return callback err if err
readGraphFile toPath, options, (err, toGraph) ->
return callback err if err
options.format = 'object' # already loaded
out = exports.diff fromGraph, toGraph, options
return callback null, out
exports.diffFiles = diffFiles
exports.main = main = () ->
[_node, _script, from, to] = process.argv
callback = (err, output) ->
throw err if err
console.log output
options = {}
options = normalizeOptions options
return diffFiles from, to, options, callback