util  Check-in [f996abb5e5]

Overview
Comment:commit to preserve old code im about to axe, parvan is broken currently
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: f996abb5e5417727e253955fa320a03e12357c588f1f2c51e58e1c313c710900
User & Date: lexi on 2022-04-28 21:01:40
Other Links: manifest | tags
Context
2022-04-28
22:52
clean up rels mechanism, get parvan mostly working again check-in: bc37f02701 user: lexi tags: trunk
21:01
commit to preserve old code im about to axe, parvan is broken currently check-in: f996abb5e5 user: lexi tags: trunk
2022-04-26
02:02
add syn check-in: 0f6a5bda23 user: lexi tags: trunk
Changes

Modified parvan.lua from [2e5da05ad6] to [30400bd2f4].

     8      8   --      SSD  Social Sciences Directorate
     9      9   --       ELS External Linguistics Subdirectorate
    10     10   --    +WSO   Worlds Security Overdirectorate
    11     11   --      EID  External Influence Directorate ]
    12     12   
    13     13   local function implies(a,b) return a==b or not(a) end
    14     14   
           15  +local function map(lst,fn)
           16  +	local new = {}
           17  +	for k,v in pairs(lst) do
           18  +		local nv, nk = fn(v,k)
           19  +		new[nk or k] = nv
           20  +	end
           21  +	return new
           22  +end
           23  +local function mapD(lst,fn) --destructive
           24  +	-- WARNING: this will not work if nk names an existing key!
           25  +	for k,v in pairs(lst) do
           26  +		local nv, nk = fn(v,k)
           27  +		if nk == nil or k == nk then
           28  +			lst[k] = nv
           29  +		else
           30  +			lst[k] = nil
           31  +			lst[nk] = nv
           32  +		end
           33  +	end
           34  +	return lst
           35  +end
           36  +local function pushi(dest, idx, src, ...)
           37  +	if not src then return end
           38  +	dest[idx] = src
           39  +	pushi(dest, idx+1, ...)
           40  +end
           41  +local function push(dest, ...) pushi(dest,#dest+1,...) end
           42  +local function cons(car, cdr)
           43  +	local new = {car}
           44  +	for k,v in ipairs(cdr) do new[k+1] = v end
           45  +	return new
           46  +end
           47  +local function tcatD(dest, ...)
           48  +	local i = #dest
           49  +	local function iter(src, ...)
           50  +		if src == nil then return end
           51  +		local sc = #src
           52  +		for j=1,sc do dest[i+j] = src[j] end
           53  +		i = i + sc
           54  +		iter(...)
           55  +	end
           56  +	iter(...)
           57  +end
           58  +local function tcat(...)
           59  +	local new = {}
           60  +	tcatD(new, ...)
           61  +	return new
           62  +end
    15     63   local ansi = {
    16     64   	levels = {
    17     65   		plain = 0;
    18     66   		ansi = 1;
    19     67   		color = 2;
    20     68   		color8b = 3;
    21     69   		color24b = 4;
    22     70   	};
    23     71   }
    24     72   
    25     73   ansi.seqs = {
    26         -	br = {ansi.levels.ansi, "[1m", "[21m"};
           74  +	br = {ansi.levels.ansi, "[1m", "[22m"};
    27     75   	hl = {ansi.levels.ansi, "[7m", "[27m"};
    28     76   	ul = {ansi.levels.ansi, "[4m", "[24m"};
    29     77   	em = {ansi.levels.ansi, "[3m", "[23m"};
    30     78   };
    31     79   
    32     80   function ansi.termclass(fd) -- awkwardly emulate isatty
    33     81   	if fd:seek('cur',0) then
................................................................................
    83    131   	if cl == ansi.levels.color24b then
    84    132   		function f.rgb(str, r,g,b, bg)
    85    133   			return string.format("\27[%c8;2;%u;%u;%um", bg and 0x34 or 0x33,
    86    134   				ftoi(r,g,b)) .. str .. reset
    87    135   		end
    88    136   	elseif cl == ansi.levels.color8b then
    89    137   		function f.rgb(str, r,g,b, bg)
    90         -			local code = 16 + (r * 5)*36 + (g * 5)*6 + (b * 6)
          138  +			local code = 16 + math.floor(r * 5)*36 + math.floor(g * 5)*6 + math.floor(b * 6)
    91    139   			return string.format("\27[%c8;5;%um", bg and 0x34 or 0x33, code)
    92    140   				.. str .. reset
    93    141   		end
    94    142   	elseif cl == ansi.levels.color then
    95    143   		function f.rgb(str, r,g,b, bg)
    96    144   			local code = 0x30 + 1 -- TODO
    97    145   			return string.format("\27[%c%cm", bg and 0x34 or 0x33, code)
................................................................................
   105    153   	return f
   106    154   end
   107    155   
   108    156   
   109    157   local function dump(v,pfx,cyc,ismeta)
   110    158   	pfx = pfx or ''
   111    159   	cyc = cyc or {}
   112         -	local np = pfx .. '  '
          160  +	local np = pfx .. '   '
   113    161   
   114    162   	if type(v) == 'table' then
   115    163   		if cyc[v] then return '<...>' else cyc[v] = true end
   116    164   	end
   117    165   
   118    166   	if type(v) == 'string' then
   119    167   		return string.format('%q', v)
................................................................................
   126    174   		local meta = ''
   127    175   		if getmetatable(v) then
   128    176   			meta = dump(getmetatable(v),pfx,cyc,true) .. '::'
   129    177   		end
   130    178   		if ismeta then
   131    179   			return string.format('%s<|\n%s%s|>',meta,str,pfx)
   132    180   		else
   133         -			return meta..'{\n' .. str .. pfx .. '}\n'
          181  +			return meta..'{\n' .. str .. pfx .. '}'
   134    182   		end
   135    183   	else
   136    184   		return string.format('%s', v)
   137    185   	end
   138    186   end
   139    187   
   140    188   local struct = {
................................................................................
   186    234   fmt.string = qpack "s4"
   187    235   fmt.label = qpack "s2"
   188    236   fmt.tag = qpack "s1"
   189    237   fmt.u8 = qpack "I1"
   190    238   fmt.u16 = qpack "I2"
   191    239   fmt.u24 = qpack "I3"
   192    240   fmt.u32 = qpack "I4"
          241  +fmt.path = {
          242  +	-- encodes a FIXED path to an arbitrary type of object
          243  +	encode = function(a)
          244  +		local kind = 0
          245  +		local vals = {}
          246  +		if a.w then kind = 1
          247  +			table.insert(vals, marshal(fmt.label, a.w))
          248  +			if a.dn then kind = 2
          249  +				table.insert(vals, marshal(fmt.u8, a.dn))
          250  +				if a.mn then kind = 3
          251  +					table.insert(vals, marshal(fmt.u8, a.mn))
          252  +					if a.nn then kind = 4
          253  +						table.insert(vals, marshal(fmt.u8, a.nn))
          254  +					end
          255  +				end
          256  +			end
          257  +		end
          258  +		return marshal(fmt.u8,kind) .. table.concat(vals)
          259  +	end;
          260  +	decode = function(s)
          261  +		local kind <const> = parse(fmt.u8, s)
          262  +		local path = {}
          263  +		local components <const> = {
          264  +			{'w',fmt.label};
          265  +			{'dn',fmt.u8};
          266  +			{'mn',fmt.u8};
          267  +			{'nn',fmt.u8};
          268  +		}
          269  +		for i=1,kind do
          270  +			local label, ty = table.unpack(components[i])
          271  +			path[label] = parse(ty,s)
          272  +		end
          273  +		return path
          274  +	end;
          275  +}
   193    276   fmt.list = function(t,ty) ty = ty or fmt.u32
   194    277   	return {
   195    278   		encode = function(a)
   196    279   			local vals = {marshal(ty, #a)}
   197    280   			for i=1,#a do
   198    281   				table.insert(vals, marshal(t, a[i]))
   199    282   			end
................................................................................
   228    311   			local m = {}
   229    312   			for _,p in pairs(lst) do m[p.key] = p.val end
   230    313   			return m
   231    314   		end;
   232    315   	}
   233    316   end
   234    317   
   235         -fmt.form = {
   236         -	{'form', fmt.u16};
   237         -	{'text', fmt.label};
   238         -}
          318  +fmt.enum = function(...)
          319  +	local vals,rmap = {...},{}
          320  +	for k,v in pairs(vals) do rmap[v] = k-1 end
          321  +	local ty = fmt.u8
          322  +	if #vals > 0xffff then ty = fmt.u32 -- just in pathological case
          323  +	elseif #vals > 0xff then ty = fmt.u16 end
          324  +	return {
          325  +		encode = function(a)
          326  +			if not rmap[a] then error(string.format('"%s" is not part of enum "%s"', a, table.concat(vals,'","')),3) end
          327  +			return marshal(ty, rmap[a])
          328  +		end;
          329  +		decode = function(s)
          330  +			local n = parse(ty,s)
          331  +			if (n+1) > #vals then error(string.format('enum "%s" does not have %u members', table.concat(vals,'","'),n),3) end
          332  +			return vals[n+1]
          333  +		end;
          334  +	}
          335  +end
   239    336   
   240    337   fmt.note = {
   241    338   	{'kind', fmt.tag};
   242    339   	{'paras', fmt.list(fmt.string)};
   243    340   }
   244    341   
          342  +fmt.example = {
          343  +	{'quote',fmt.string};
          344  +	{'src',fmt.label};
          345  +}
   245    346   fmt.meaning = {
   246    347   	{'lit', fmt.string};
          348  +	{'examples', fmt.list(fmt.example,fmt.u8)};
   247    349   	{'notes', fmt.list(fmt.note,fmt.u8)};
   248    350   }
          351  +
          352  +fmt.phrase = {
          353  +	{'str',fmt.label};
          354  +	{'means',fmt.list(fmt.meaning,fmt.u8)};
          355  +	{'xref',fmt.list(fmt.path,fmt.u16)};
          356  +}
   249    357   
   250    358   fmt.def = {
   251    359   	{'part', fmt.u8};
   252    360   	{'branch', fmt.list(fmt.label,fmt.u8)};
   253    361   	{'means', fmt.list(fmt.meaning,fmt.u8)};
   254         -	{'forms', fmt.list(fmt.form,fmt.u16)};
          362  +	{'forms', fmt.map(fmt.u16,fmt.label,fmt.u16)};
          363  +	{'phrases', fmt.list(fmt.phrase,fmt.u16)};
   255    364   }
   256    365   
   257    366   fmt.word = {
   258    367   	{'defs', fmt.list(fmt.def,fmt.u8)};
   259    368   }
   260    369   
   261    370   fmt.dictHeader = {
   262    371   	{'lang', fmt.tag};
   263    372   	{'meta', fmt.string};
   264    373   	{'partsOfSpeech', fmt.list(fmt.tag,fmt.u16)};
          374  +	{'inflectionForms', fmt.list({
          375  +		{'name', fmt.tag};
          376  +		{'abbrev', fmt.tag};
          377  +		{'desc', fmt.string};
          378  +		{'parts', fmt.list(fmt.tag,fmt.u8)};
          379  +		-- which parts of speech does this form apply to?
          380  +		-- leave empty if not relevant
          381  +	},fmt.u16)};
   265    382   }
   266    383   
   267         -fmt.synonymSet = {
          384  +fmt.relSet = {
   268    385   	{'uid', fmt.u32};
   269    386   		-- IDs are persistent random values so they can be used
   270    387   		-- as reliable identifiers even when merging exports in
   271    388   		-- a parvan-unaware VCS
   272         -	{'members', fmt.list({
   273         -		{'word', fmt.label}, {'def', fmt.u8};
   274         -	},fmt.u16)};
          389  +	{'members', fmt.list(fmt.path,fmt.u16)};
          390  +	{'kind', fmt.enum('syn','ant','met')};
   275    391   }
   276    392   
   277    393   fmt.dict = {
   278    394   	{'header', fmt.dictHeader};
   279    395   	{'words', fmt.map(fmt.string,fmt.word)};
   280         -	{'synonyms', fmt.list(fmt.synonymSet)};
          396  +	{'relsets', fmt.list(fmt.relSet)};
   281    397   }
   282    398   
   283    399   function marshal(ty, val)
   284    400   	if ty.encode then
   285    401   		return ty.encode(val)
   286    402   	end
   287    403   	local ac = {}
   288    404   
   289    405   	for idx,fld in ipairs(ty) do
   290    406   		local name, fty = table.unpack(fld)
   291         -		table.insert(ac, marshal(fty, assert(val[name])))
          407  +		table.insert(ac, marshal(fty,
          408  +			assert(val[name],
          409  +				string.format('marshalling error: missing field %s', name)
          410  +			)
          411  +		))
   292    412   	end
   293    413   
   294    414   	return table.concat(ac)
   295    415   end
   296    416   
   297    417   function parse(ty, stream)
   298    418   	if ty.decode then
................................................................................
   315    435   		if map[v] then return map[v] else
   316    436   			map[v] = i
   317    437   			i=i+1
   318    438   			return i-1
   319    439   		end
   320    440   	end, map
   321    441   end
          442  +
          443  +local function rebuildRelationCache(d)
          444  +-- (re)build a dictionary's relation cache; needed
          445  +-- at load time and whenever any changes to relsets
          446  +-- are made (unless they're simple enough to update
          447  +-- the cache directly by hand, but that's very eeeh)
          448  +	local sc = {}
          449  +	for i,s in ipairs(d.relsets) do
          450  +		for j,m in ipairs(s.members) do
          451  +			sc[m.w] = sc[m.w] or {}
          452  +			table.insert(sc[m.w], s)
          453  +		end
          454  +	end
          455  +	d._relCache = sc
          456  +end
   322    457   
   323    458   local function
   324    459   writeDict(d)
   325    460   	local atomizePoS, posMap = atomizer()
   326    461   	for lit,w in pairs(d.words) do
   327    462   		for j,def in ipairs(w.defs) do
   328    463   			def.part = atomizePoS(def.part)
................................................................................
   345    480   	local d = parse(fmt.dict, s)
   346    481   	-- handle atoms
   347    482   	for lit,w in pairs(d.words) do
   348    483   		for j,def in ipairs(w.defs) do
   349    484   			def.part = d.header.partsOfSpeech[def.part]
   350    485   		end
   351    486   	end
          487  +
          488  +	-- create cachemaps for complex data structures to
          489  +	-- enable faster lookup that would otherwise require
          490  +	-- expensive scans
          491  +	rebuildRelationCache(d)
   352    492   	return d
   353    493   end
          494  +
   354    495   
   355    496   local function strwords(str) -- here be dragons
   356    497   	local wds = {}
   357    498   	local w = {}
   358    499   	local state, d, quo, dquo = 0,0
   359         -	local function flush(n)
   360         -		if next(w) then
          500  +	local function flush(n,final)
          501  +		if next(w) or state ~= 0 and state < 10 then
   361    502   			table.insert(wds, utf8.char(table.unpack(w)))
   362    503   			w = {}
          504  +		elseif final and state > 10 then
          505  +			table.insert(wds, '\\')
   363    506   		end
   364    507   		state = n
   365    508   		quo = nil
   366    509   		dquo = nil
   367    510   		d = 0
   368    511   	end
   369    512   	local function isws(c)
................................................................................
   418    561   				table.insert(w,0x0a)
   419    562   			else
   420    563   				table.insert(w,cp)
   421    564   			end
   422    565   			state = state - 10
   423    566   		end
   424    567   	end
   425         -	flush()
          568  +	flush(nil,true)
   426    569   	return wds
   427    570   end
   428    571   
   429    572   local predicates
   430    573   local function parsefilter(str)
   431    574   	local f = strwords(str)
   432    575   	if #f == 1 then return function(e) return predicates.lit.fn(e,f[1]) end end
................................................................................
   631    774   
   632    775   local function copy(tab)
   633    776   	local new = {}
   634    777   	for k,v in pairs(tab) do new[k] = v end
   635    778   	return new
   636    779   end
   637    780   
   638         -local function parsePath(p)
   639         -	local w,dn,mn,nn = p:match('^(.+)@([0-9]+)/([0-9]+):([0-9]+)$')
   640         -	if not w then w,dn,mn = p:match('^(.+)@([0-9]+)/([0-9]+)$') end
   641         -	if not w then w,dn = p:match('^(.+)@([0-9]+)$') end
          781  +local function pathParse(p)
          782  +-- this is cursed, rewrite without regex pls TODO
          783  +	if p == '.' then return {} end
          784  +	local function comp(pfx)
          785  +		return pfx .. '([0-9]+)'
          786  +	end
          787  +	local function mtry(...)
          788  +		local mstr = '^(.+)'
          789  +		for _, v in ipairs{...} do
          790  +			mstr = mstr .. comp(v)
          791  +		end
          792  +		return p:match(mstr .. '$')
          793  +	end
          794  +
          795  +	local xn
          796  +	local w,dn,pn,mn,nn = mtry('%.','/p','/m','/n')
          797  +	if not w then w,dn,pn,mn,xn = mtry('%.','/p','/m','/x') end
          798  +	if not w then w,dn,pn,mn = mtry('%.','/p','/m') end
          799  +	if not w then w,dn,pn= mtry('%.','/p') end
          800  +	if not w then
          801  +		local comps = {'%.','/m','/n'}
          802  +		for i=#comps, 1, -1 do
          803  +			local args = {table.unpack(comps,1,i)}
          804  +			w,dn,mn,nn = mtry(table.unpack(args))
          805  +			if not w and args[i] == '/n' then
          806  +				args[i] = '/x'
          807  +				w,dn,mn,xn = mtry(table.unpack(args))
          808  +			end
          809  +			if w then break end
          810  +		end
          811  +	end
   642    812   	if not w then w=p:match('^(.-)%.?$') end
   643         -	return {w = w, dn = tonumber(dn), mn = tonumber(mn), nn = tonumber(nn)}
          813  +	return {w = w, dn = tonumber(dn), mn = tonumber(mn), pn=tonumber(pn); nn = tonumber(nn), xn = tonumber(xn)}
          814  +end
          815  +local function pathString(p,styler)
          816  +	local function s(s, st, ...)
          817  +		if styler then
          818  +			return styler[st](tostring(s),...)
          819  +		else return s end
          820  +	end
          821  +
          822  +	local function comp(c,n,...)
          823  +		return s('/','color',5)
          824  +		    .. s(string.format("%s%u",c,n), 'color',...)
          825  +	end
          826  +	local t = {}
          827  +	if p.w then t[1] = s(p.w,'ul') else return '.' end
          828  +	if p.dn then t[2] = string.format(".%s", s(p.dn,'br')) end
          829  +	if p.pn then t[#t+1] = comp('p',p.pn,4,true) end
          830  +	if p.mn then t[#t+1] = comp('m',p.mn,5,true) end
          831  +	if p.xn then t[#t+1] = comp('x',p.xn,6,true)
          832  +	elseif p.nn then t[#t+1] = comp('n',p.nn,4) end
          833  +	if t[2] == nil then
          834  +		return p.w .. '.' --make sure paths are always valid
          835  +	end
          836  +	return s(table.concat(t),'em')
          837  +end
          838  +local function pathMatch(a,b)
          839  +	return a.w  == b.w
          840  +		and a.dn == b.dn
          841  +		and a.mn == b.mn
          842  +		and a.pn == b.pn
          843  +		and a.nn == b.nn
          844  +		and a.xn == b.xn
          845  +end
          846  +local function pathResolve(ctx, a)
          847  +	if not a.w then return end -- empty paths are valid!
          848  +	local function lookup(seg, tbl,val)
          849  +		if not tbl then error('bad table',2) end
          850  +		local v = tbl[val]
          851  +		if v then return v end
          852  +		id10t('bad %s in path: %s', seg, val)
          853  +	end
          854  +
          855  +	local res = {}
          856  +	res.word = lookup('word', ctx.dict.words, a.w)
          857  +	if not a.dn then return res end
          858  +
          859  +	res.def = lookup('definition', w.defs, a.dn)
          860  +	if (not a.pn) and (not a.mn) then return res end
          861  +
          862  +	local m if a.pn then
          863  +		res.phrase = lookup('phrase', d.phrases, a.pn)
          864  +		res.meaning = lookup('meaning', p.means, a.mn)
          865  +	else
          866  +		res.meaning = lookup('meaning', d.means, a.mn)
          867  +	end
          868  +
          869  +	if a.xn then
          870  +		res.ex = lookup('example',m.examples,a.xn)
          871  +	elseif a.nn then
          872  +		res.note = lookup('note',m.notes,a.nn)
          873  +	end
          874  +
          875  +	return res
          876  +end
          877  +
          878  +local function pathNav(...)
          879  +	local t = pathResolve(...)
          880  +	return t.word,t.def,t.phrase,t.meaning,t.ex or t.note
          881  +end
          882  +
          883  +local function pathRef(ctx, a)
          884  +	local w,d,p,m,n = pathNav(ctx,a)
          885  +	return n or m or p or d or w
          886  +end
          887  +
          888  +local function pathSub(super,sub)
          889  +	if super.w == nil then return true end
          890  +	if sub.w ~= super.w then return false end
          891  +
          892  +	if super.pn == nil then goto checkMN end
          893  +	if sub.pn ~= super.pn then return false end
          894  +
          895  +	::checkMN::
          896  +	if super.mn == nil then return true end
          897  +	if sub.mn ~= super.mn then return false end
          898  +
          899  +	if super.xn then
          900  +		if sub.nn then return false end
          901  +		if sub.xn ~= super.xn then return false end
          902  +	elseif super.nn then
          903  +		if sub.xn then return false end
          904  +		if sub.nn ~= super.nn then return false end
          905  +	end
          906  +
          907  +	return true
   644    908   end
   645    909   
   646    910   local cmds = {
   647    911   	create = {
   648    912   		help = "initialize a new dictionary file";
   649    913   		syntax = "<lang>";
   650    914   		raw = true;
................................................................................
   654    918   			end
   655    919   			local fd = safeopen(ctx.file,"wb")
   656    920   			local new = {
   657    921   				header = {
   658    922   					lang = lang;
   659    923   					meta = "";
   660    924   					partsOfSpeech = {};
   661         -					branch = {};
          925  +					inflectionForms = {};
   662    926   				};
   663    927   				words = {};
   664         -				synonyms = {};
          928  +				relsets = {};
   665    929   			}
   666    930   			local o = writeDict(new);
   667    931   			fd:write(o)
   668    932   			fd:close()
   669    933   		end;
   670    934   	};
   671    935   	coin = {
................................................................................
   691    955   			if not ctx.dict.words[word] then
   692    956   				ctx.dict.words[word] = {defs={}}
   693    957   			end
   694    958   			local n = #(ctx.dict.words[word].defs)+1
   695    959   			ctx.dict.words[word].defs[n] = {
   696    960   				part = part;
   697    961   				branch = etym;
   698         -				means = {means and {lit=means,notes={}} or nil};
          962  +				means = {means and {
          963  +					lit=means;
          964  +					examples={};
          965  +					notes={};
          966  +				} or nil};
   699    967   				forms = {};
   700    968   			}
   701    969   			ctx.log('info', string.format('added definition #%u to “%s”', n, word))
   702    970   		end;
   703    971   	};
   704    972   	mean = {
   705    973   		help = "add a meaning to a definition";
   706    974   		syntax = "<word> <def#> <meaning>";
   707    975   		write = true;
   708    976   		exec = function(ctx,word,dn,m)
   709         -			local _,d = safeNavWord(ctx,word,dn)
   710         -			table.insert(d.means, {lit=m,notes={}})
          977  +			local t = pathResolve(ctx,{w=word,dn=dn})
          978  +			table.insert(t.d.means, {lit=m,notes={}})
   711    979   		end;
   712    980   	};
   713         -	syn = {
   714         -		help = "manage synonym groups";
          981  +	rel = {
          982  +		help = "manage groups of related words";
   715    983   		syntax = {
   716         -			"(show|purge) <path>";
          984  +			"(show|purge) <path> [<kind>]";
   717    985   			"(link|drop) <word> <group#> <path>…";
   718         -			"new <path> <path>…";
   719         -			"clear <word> [<group#>]";
          986  +			"new <rel> <path> <path>…";
          987  +			"destroy <word> [<group#>]";
          988  +			"rel ::= (syn|ant|co)"
   720    989   		};
   721    990   		write = true;
   722         -		exec = function(ctx, op, tgtw, ...)
          991  +		exec = function(ctx, op, ...)
          992  +			local fo = ctx.sty[io.stdout]
          993  +			if op == nil  then id10t "not enough arguments" end
   723    994   			local groups = {}
   724         -			local wp = parsePath(tgtw)
   725         -			local w,d = safeNavWord(ctx, wp.w, wp.dn)
   726         -			if not (op=='new' or op=='link' or op=='drop' or op=='clear' or op=='show' or op=='purge') then
   727         -				id10t('invalid operation “%s” for `syn`', op)
          995  +			if not (op=='new' or op=='link' or op=='drop' or op=='destroy' or op=='show' or op=='purge') then
          996  +				id10t('invalid operation “%s” for `rel`', op)
   728    997   			end
   729    998   			if op == 'new' then
   730         -				local links = {{word = wp.w, def = wp.dn or 1}}
   731         -				for i,l in ipairs{...} do
   732         -					local parsed = parsePath(l)
   733         -					links[i+1] = {word = parsed.w, def = parsed.dn or 1}
          999  +				local rel = ...
         1000  +				if rel ~= 'syn' and rel ~= 'ant' and rel ~= 'met' then
         1001  +					id10t 'relationships must be synonymous, antonymous, or metonymous'
   734   1002   				end
   735         -				table.insert(ctx.dict.synonyms, {
   736         -					uid=math.random(0,0xffffFFFF);
         1003  +				local links={}
         1004  +				for i,l in ipairs{select(2,...)} do
         1005  +					links[i] = pathParse(l)
         1006  +				end
         1007  +				local newstruct = {
         1008  +					uid=math.random(1,0xffffFFFF);
   737   1009   					members=links;
   738         -				})
         1010  +					kind = rel;
         1011  +				}
         1012  +				table.insert(ctx.dict.relsets, newstruct)
         1013  +
         1014  +				local rc = ctx.dict._relCache
         1015  +				for i,l in pairs(links) do
         1016  +					rc[l.w] = rc[l.w] or {}
         1017  +					table.insert(rc[l.w], newstruct)
         1018  +				end
         1019  +				rebuildRelationCache(ctx.dict)
   739   1020   			else -- assemble a list of groups
   740         -				for i,ss in ipairs(ctx.dict.synonyms) do
         1021  +				local tgtw = ...
         1022  +				local wp = pathParse(tgtw)
         1023  +				local w,d,m = pathNav(ctx, wp)
         1024  +				for i,ss in ipairs(ctx.dict.relsets) do
   741   1025   					for j,s in ipairs(ss.members) do
   742         -						if s.word == wp.w and (wp.dn == nil or s.def == wp.dn) then
   743         -							table.insert(groups, {set = ss, mem = s})
         1026  +						if pathSub(s, wp) then
         1027  +-- 						if s.word == wp.w and (wp.dn == nil or s.def == wp.dn) then
         1028  +							table.insert(groups, {set = ss, mem = s, id = i})
   744   1029   							break
   745   1030   						end
   746   1031   					end
   747   1032   				end
   748   1033   
   749   1034   				if op == 'show' then
   750   1035   					for i, g in ipairs(groups) do
   751         -						local w,d = safeNavWord(ctx, g.mem.word, g.mem.def)
   752         -						local function label(wd,defn)
   753         -							local fulldef = {}
   754         -							for i,v in ipairs(defn.means) do
   755         -								fulldef[i] = v.lit
         1036  +						local w = pathResolve(ctx, {w=g.mem.w}).w
         1037  +						local function label(path,w)
         1038  +							local repr = path.w
         1039  +							if path.dn then
         1040  +								repr = repr .. string.format("(%s)", w.defs[path.dn].part)
         1041  +								if path.mn then
         1042  +									repr = repr .. string.format(": %u. %s", path.dn, w.defs[path.dn].means[path.mn].lit)
         1043  +								else
         1044  +									local fulldef = {}
         1045  +									for i,v in ipairs(w.defs) do
         1046  +										fulldef[i] = v.lit
         1047  +									end
         1048  +									repr = repr..table.concat(fulldef, '; ')
         1049  +								end
   756   1050   							end
   757         -							fulldef = table.concat(fulldef, '; ')
   758         -							return string.format("%s(%s): %s",wd,defn.part,fulldef)
         1051  +
         1052  +							return repr
   759   1053   						end
         1054  +
   760   1055   						local others = {}
   761   1056   						for j, o in ipairs(g.set.members) do
   762         -							if not (o.word == g.mem.word and o.def == (wp.dn or 1)) then
   763         -								local ow, od = safeNavWord(ctx, o.word,o.def)
   764         -								table.insert(others, '      '..label(o.word,od))
         1057  +							local ow = pathResolve(ctx, {w=o.w}).w
         1058  +							if (g.set.kind == 'ant' or not pathMatch(o, g.mem)) and
         1059  +								--exclude antonym headwords
         1060  +								not (g.set.kind == 'ant' and j==1) then
         1061  +								table.insert(others, '      '..label(o,ow))
         1062  +							end
         1063  +						end
         1064  +						local llab do
         1065  +							local cdw = ctx.dict.words
         1066  +							if g.set.kind == 'ant' then
         1067  +								local ap = g.set.members[1]
         1068  +								llab = fo.br(label(ap,cdw[ap.w]) or '')
         1069  +							else
         1070  +								llab = fo.br(label(g.mem,cdw[g.mem.w]) or '')
   765   1071   							end
   766   1072   						end
   767         -						io.stdout:write(string.format("% 4u) %s\n%s", i, label(g.mem.word,d),table.concat(others,'\n')))
         1073  +						local kls = {
         1074  +							syn = fo.color('synonyms',2,true)..' of';
         1075  +							ant = fo.color('antonyms',1,true)..' of';
         1076  +							met = fo.color('metonyms',4,true)..' of';
         1077  +						}
         1078  +						io.stdout:write(string.format("% 4u) %s\n%s", i, fo.ul(kls[g.set.kind] .. ' ' .. llab), table.concat(others,'\n')) .. '\n')
   768   1079   					end
         1080  +					return false -- no changes made
   769   1081   				elseif op == 'link' or op == 'drop' then
   770         -					local tgtn, paths = (...), { select(2, ...) }
         1082  +					local tgtn, paths = (select(2,...)), { select(3, ...) }
         1083  +					rebuildRelationCache(ctx.dict)
         1084  +				elseif op == 'destroy' then
         1085  +					local tgtw, tgtn = ...
         1086  +					if not tgtn then id10t 'missing group number' end
         1087  +					local delendum = groups[tonumber(tgtn)]
         1088  +					if not delendum then id10t 'bad group number' end
         1089  +					local rs = ctx.dict.relsets
         1090  +					local last = #rs
         1091  +					if delendum.id == last then
         1092  +						rs[delendum.id] = nil
         1093  +					else -- since order doesn't matter, we can use a
         1094  +						-- silly swapping trick to reduce the deletion
         1095  +						-- worst case from O(n) to O(2)
         1096  +						rs[delendum.id] = rs[last]
         1097  +						rs[last] = nil
         1098  +					end
         1099  +					rebuildRelationCache(ctx.dict)
         1100  +				else
         1101  +					id10t 'invalid operation'
   771   1102   				end
   772   1103   			end
   773   1104   		end;
   774   1105   	};
   775   1106   	mod = {
   776   1107   		help = "move, merge, split, or delete words or definitions";
   777   1108   		syntax = {
................................................................................
   783   1114   	note = {
   784   1115   		help = "add a note to a definition or a paragraph to a note";
   785   1116   		syntax = {"(<m-path> (add|for) <kind> | <m-path>:<note#>) <para>…";
   786   1117   			"m-path ::= <word>@<def#>/<meaning#>"};
   787   1118   		write = true;
   788   1119   		exec = function(ctx,path,...)
   789   1120   			local paras, mng
   790         -			local dest = parsePath(path)
         1121  +			local dest = pathParse(path)
   791   1122   			local _,_,m = safeNavWord(ctx,dest.w,dest.dn,dest.mn)
   792   1123   			if dest.nn then
   793   1124   				paras = {...}
   794   1125   			else
   795   1126   				local op, kind = ...
   796   1127   				paras = { select(3, ...) }
   797   1128   				if op == 'add' then
................................................................................
   828   1159   	predicates = {
   829   1160   		help = "show available filter predicates";
   830   1161   		nofile = true;
   831   1162   		syntax = "[<predicate>]";
   832   1163   	};
   833   1164   	export = {
   834   1165   		help = "create a text file dump compatible with source control";
         1166  +		syntax = "[<target-file>]";
         1167  +	};
         1168  +	import = {
         1169  +		help = "generate a usable dictionary from a text export file";
         1170  +		syntax = "[<input-file>]";
         1171  +		raw = true;
         1172  +		write = true;
   835   1173   	};
   836   1174   	dump = {
   837   1175   		exec = function(ctx) print(dump(ctx.dict)) end
   838   1176   	};
   839   1177   	ls = {
   840   1178   		help = "list all words that meet any given <filter>";
   841   1179   		syntax = {"[<filter>…]";
................................................................................
   848   1186   	local list = predicates
   849   1187   	if pred then list = {predicates[pred]} end
   850   1188   	local f = ctx.sty[io.stderr]
   851   1189   	for k,p in pairs(predicates) do
   852   1190   		if p.help then
   853   1191   			io.stderr:write(
   854   1192   				f.br('  - ' ..
   855         -					f.rgb('[',.8,.3,1) ..
         1193  +					f.rgb('[',1,0,.5) ..
   856   1194   					k .. ' ' ..
   857   1195   					(f.color(p.syntax,5) or '…') ..
   858         -					f.rgb(']',.8,.3,1)) .. ': ' ..
         1196  +					f.rgb(']',1,0,.5)) .. ': ' ..
   859   1197   				f.color(p.help,4,true) .. '\n')
   860   1198   		end
   861   1199   	end
   862   1200   end
   863   1201   
   864   1202   function cmds.ls.exec(ctx,...)
   865   1203   	local filter = nil
................................................................................
   875   1213   		local e = {lit=lit, word=w}
   876   1214   		if filter == nil or filter(e) then
   877   1215   			table.insert(out, e)
   878   1216   		end
   879   1217   	end
   880   1218   	table.sort(out, function(a,b) return a.lit < b.lit end)
   881   1219   	local fo = ctx.sty[io.stdout]
   882         -	local function meanings(d,md,n)
         1220  +
         1221  +	local function gatherRelSets(path)
         1222  +		local antonymSets, synonymSets, metonymSets = {},{},{}
         1223  +		if ctx.dict._relCache[path.w] then
         1224  +			for i, rel in ipairs(ctx.dict._relCache[path.w]) do
         1225  +				local specuset,tgt,anto = {}
         1226  +				for j, mbr in ipairs(rel.members) do
         1227  +					if pathMatch(mbr, path) then
         1228  +						if     rel.kind == 'syn' then tgt = synonymSets
         1229  +						elseif rel.kind == 'met' then tgt = metonymSets
         1230  +						elseif rel.kind == 'ant' then
         1231  +							if j == 1 -- is this the headword?
         1232  +								then tgt = antonymSets
         1233  +								else tgt = synonymSets
         1234  +							end
         1235  +						end
         1236  +					elseif j == 1 and rel.kind == 'ant' then
         1237  +						anto = mbr
         1238  +					else
         1239  +						table.insert(specuset, mbr)
         1240  +					end
         1241  +				end
         1242  +				if tgt then
         1243  +					table.insert(tgt, specuset)
         1244  +					if anto then
         1245  +						table.insert(antonymSets, {anto})
         1246  +					end
         1247  +				end
         1248  +			end
         1249  +		end
         1250  +		local function flatten(lst)
         1251  +			local new = {}
         1252  +			for i, l in ipairs(lst) do tcatD(new, l) end
         1253  +			return new
         1254  +		end
         1255  +		return {
         1256  +			syn = flatten(synonymSets);
         1257  +			ant = flatten(antonymSets);
         1258  +			met = flatten(metonymSets);
         1259  +		}
         1260  +	end
         1261  +
         1262  +	local function formatRels(rls, padlen)
         1263  +		-- optimize for the common case
         1264  +		if next(rls.syn) == nil and
         1265  +		   next(rls.ant) == nil and
         1266  +			next(rls.met) == nil then return {} end
         1267  +		local pad = string.rep(' ',padlen)
         1268  +		local function format(label, set)
         1269  +			local each = map(set, function(e)
         1270  +				local ew,ed = pathNav(ctx, e)
         1271  +            local str = fo.ul(e.w)
         1272  +				if ed then str = string.format('%s(%s)',str,ed.part) end
         1273  +				if e.mn then str = string.format('%s§%u',str,e.mn) end
         1274  +				return str
         1275  +			end)
         1276  +			return fo.em(string.format("%s%s %s",pad,label,table.concat(each,', ')))
         1277  +		end
         1278  +		local lines = {}
         1279  +		local function add(l,c,lst)
         1280  +			table.insert(lines, format(fo.color(l,c,true),lst))
         1281  +		end
         1282  +		if next(rls.syn) then add('synonyms:',2,rls.syn) end
         1283  +		if next(rls.ant) then add('antonyms:',1,rls.ant) end
         1284  +		if next(rls.met) then add('metonyms:',4,rls.met) end
         1285  +		return lines
         1286  +	end
         1287  +
         1288  +	local function meanings(w,d,md,n)
   883   1289   		local start = md and 2 or 1
   884   1290   		local part = string.format('(%s)', d.part)
   885   1291   		local pad = md and string.rep(' ', #part) or ''
   886   1292   		local function note(n,insert)
   887   1293   			if not next(n.paras) then return end
   888   1294   			local pad = string.rep(' ',#(n.kind) + 9)
   889   1295   			insert('      ' .. fo.hl(' ' .. n.kind .. ' ') .. ' ' .. n.paras[1])
   890   1296   			for i=2,#n.paras do
   891   1297   				insert(pad..n.paras[2])
   892   1298   			end
   893   1299   		end
   894   1300   		local m = { (function()
   895   1301   			if d.means[1] then
   896         -				if md then return
   897         -					string.format("  %s 1. %s", fo.em(part), d.means[1].lit)
         1302  +				if md then
         1303  +					local id = ''
         1304  +					if ctx.flags.ident then
         1305  +						id=' ['..pathString({w=w.lit,dn=n,mn=1}, fo)..']'
         1306  +					end
         1307  +					return string.format(" %s %s 1. %s", id, fo.em(part), d.means[1].lit)
   898   1308   				end
   899   1309   			else return
   900   1310   				fo.em(string.format('  %s [empty definition #%u]', part,n))
   901   1311   			end
   902   1312   		end)() }
         1313  +		tcatD(m, formatRels(gatherRelSets{w=w.lit,dn=n,mn=1}, 6))
   903   1314   		for i=start,#d.means do local v = d.means[i]
   904         -			table.insert(m, string.format('  %s %u. %s', pad, i, v.lit))
         1315  +			local id = ''
         1316  +			if ctx.flags.ident then id='['..pathString({w=w.lit,dn=n,mn=n}, fo)..']' end
         1317  +			table.insert(m, string.format('  %s%s %u. %s', pad, id, i, v.lit))
         1318  +			tcatD(m, formatRels(gatherRelSets{w=w.lit,dn=n,mn=i}, 6))
   905   1319   			for j,n in ipairs(v.notes) do
   906   1320   				note(n, function(v) table.insert(m, v) end)
   907   1321   			end
   908   1322   		end
   909   1323   		return table.concat(m,'\n')
   910   1324   	end
         1325  +	local function autobreak(str)
         1326  +		if str ~= '' then return str..'\n' else return str end
         1327  +	end
   911   1328   	for i, w in ipairs(out) do
   912         -		local d = fo.ul(w.lit)
         1329  +		local d = fo.ul(fo.br(w.lit))
         1330  +		local wordrels = autobreak(table.concat(
         1331  +				formatRels(gatherRelSets{w=w.lit}, 2),
         1332  +				'\n'
         1333  +			))
         1334  +		local wc = ctx.dict._relCache[w.lit]
   913   1335   		if #w.word.defs == 1 then
   914         -			d=d .. ' ' .. fo.em('('..(w.word.defs[1].part)..')') ..'\n'
   915         -				.. meanings(w.word.defs[1],false,1)
         1336  +			d=d .. ' '
         1337  +			    .. fo.rgb(fo.em('('..(w.word.defs[1].part)..')'),.8,.5,1) .. '\n'
         1338  +			    .. meanings(w,w.word.defs[1],false,1) .. '\n'
         1339  +			    .. autobreak(table.concat(formatRels(gatherRelSets{w=w.lit,dn=1}, 4), '\n'))
         1340  +				 .. wordrels .. '\n'
   916   1341   		else
   917   1342   			for j, def in ipairs(w.word.defs) do
   918         -				d=d .. '\n' .. meanings(def,true,j)
   919         -			end
   920         -		end
   921         -		io.stdout:write(d..'\n')
   922         -	end
   923         -end
   924         -
   925         -function cmds.export.exec(ctx)
         1343  +				local syn if wsc and wsc[j] then syn = wsc[j] end
         1344  +				d=d .. '\n'
         1345  +				    .. meanings(w,syn,def,true,j) .. '\n'
         1346  +				    .. autobreak(table.concat(
         1347  +						 formatRels(gatherRelSets{w=w.lit,dn=j}, 4),
         1348  +						 '\n'
         1349  +					 ))
         1350  +			end
         1351  +			d=d .. wordrels .. '\n'
         1352  +		end
         1353  +		io.stdout:write(d)
         1354  +	end
         1355  +end
         1356  +
         1357  +function cmds.import.exec(ctx,file)
         1358  +	local ifd = io.stdin
         1359  +	if file then
         1360  +		ifd = safeopen(file,'r')
         1361  +	end
         1362  +
         1363  +	local new = {
         1364  +		header = {
         1365  +			lang = lang;
         1366  +			meta = "";
         1367  +			partsOfSpeech = {};
         1368  +			inflectionForms = {};
         1369  +		};
         1370  +		words = {};
         1371  +		relsets = {};
         1372  +	}
         1373  +
         1374  +	local state = 0
         1375  +	local relsets = {}
         1376  +	local path = {}
         1377  +	local inflmap, lastinfl = {}
         1378  +	for l in ifd:lines() do
         1379  +		local words = strwords(l)
         1380  +		local c = words[1]
         1381  +		local function syn(mn,mx)
         1382  +			local nw = #words - 1
         1383  +			if nw < mn or (mx ~= nil and nw > mx) then
         1384  +				if mx ~= nil then
         1385  +					id10t('command %s needs between %u~%u words',c,mn,mx)
         1386  +				else
         1387  +					id10t('command %s needs at least %u words',c,mn)
         1388  +				end
         1389  +			end
         1390  +		end
         1391  +		if c ~= '*' and c~='meta' then -- comments
         1392  +			if state == 0 then
         1393  +				if c ~= 'pv0' then
         1394  +					id10t "not a parvan export"
         1395  +				end
         1396  +				new.header.lang = words[2]
         1397  +				new.header.meta = words[3]
         1398  +				state = 1
         1399  +			else
         1400  +				print(pathString(path, ctx.sty[io.stderr]))
         1401  +				local W,D,M,N = pathNav({dict=new}, path)
         1402  +				if c == 'w' then syn(1) state = 2
         1403  +					path = {w=words[2]}
         1404  +					new.words[words[2]] = {defs={}}
         1405  +				elseif c == 'f' then syn(1)
         1406  +					local nf = {
         1407  +						name = words[2];
         1408  +						abbrev = words[3] or "";
         1409  +						desc = words[4] or "";
         1410  +						parts = {};
         1411  +					}
         1412  +					table.insert(new.header.inflectionForms, nf)
         1413  +					inflmap[words[2]] = #(new.header.inflectionForms)
         1414  +					lastinfl = nf
         1415  +				elseif c == 'fp' then syn(1)
         1416  +					if not lastinfl then
         1417  +						id10t 'fp can only be used after f' end
         1418  +					table.insert(lastinfl.parts,words[2])
         1419  +				elseif c == 's' then syn(2)
         1420  +					relsets[words[3]] = relsets[words[3]] or {}
         1421  +					relsets[words[3]].kind = words[2]
         1422  +					relsets[words[3]].uid = tonumber(words[3])
         1423  +					relsets[words[3]].members = relsets[words[3]].members or {}
         1424  +				elseif state >= 2 and c == 'r' then syn(1)
         1425  +					relsets[words[2]] = relsets[words[2]] or {
         1426  +						uid = tonumber(words[2]);
         1427  +						members={};
         1428  +					}
         1429  +					table.insert(relsets[words[2]].members, path)
         1430  +				elseif state >= 2 and c == 'd' then syn(1) state = 3
         1431  +					table.insert(W.defs, {
         1432  +						part = words[2];
         1433  +						branch = {};
         1434  +						means = {};
         1435  +						forms = {};
         1436  +						phrases = {};
         1437  +					})
         1438  +					path = {w = path.w, dn = #(W.defs)}
         1439  +				elseif state >= 3 and c == 'dr' then syn(1)
         1440  +					table.insert(D.branch, words[2])
         1441  +				elseif state >= 3 and c == 'df' then syn(2)
         1442  +					if not inflmap[words[2]] then
         1443  +						id10t('no inflection form %s defined', words[2])
         1444  +					end
         1445  +					D.forms[inflmap[words[2]]] = words[3]
         1446  +				elseif state >= 3 and c == 'm' then syn(1) state = 4
         1447  +					table.insert(D.means, {
         1448  +						lit = words[2];
         1449  +						notes = {};
         1450  +						examples = {};
         1451  +					});
         1452  +					path = {w = path.w, dn = path.dn, mn = #(D.means)}
         1453  +				elseif state >= 4 and c == 'n' then syn(1) state = 5
         1454  +					table.insert(M.notes, {kind=words[2], paras={}})
         1455  +					path = {w = path.w, dn = path.dn, mn = path.mn, nn = #(M.notes)};
         1456  +				elseif state >= 5 and c == 'np' then syn(1)
         1457  +					table.insert(N.paras, words[2])
         1458  +				end
         1459  +				-- we ignore invalid ctls, for sake of forward-compat
         1460  +			end
         1461  +		end
         1462  +	end
         1463  +
         1464  +	for k,v in pairs(relsets) do
         1465  +		if not v.uid then
         1466  +			--handle non-numeric export ids
         1467  +			v.uid = math.random(0,0xffffFFFF)
         1468  +		end
         1469  +		table.insert(new.relsets, v)
         1470  +	end
         1471  +
         1472  +	local ofd = safeopen(ctx.file,"w+b")
         1473  +	local o = writeDict(new);
         1474  +	ofd:write(o)
         1475  +	ofd:close()
         1476  +end
         1477  +
         1478  +function cmds.export.exec(ctx,file)
         1479  +	local ofd = io.stdout
         1480  +	if file then ofd = safeopen(file, 'w+') end
   926   1481   	local function san(str)
   927   1482   		local d = 0
   928   1483   		local r = {}
   929   1484   		for i,cp in utf8.codes(str) do
   930   1485   			-- insert backslashes for characters that would
   931   1486   			-- disrupt strwords() parsing
   932         -			if cp == 0x5b then
   933         -				d = d + 1
   934         -			elseif cp == 0x5d then
   935         -				if d >= 1 then
   936         -					d = d - 1
   937         -				else
   938         -					table.insert(r, 0x5c)
         1487  +			if cp == 0x0a then
         1488  +				table.insert(r, 0x5c)
         1489  +				table.insert(r, 0x6e)
         1490  +			else
         1491  +				if cp == 0x5b then
         1492  +					d = d + 1
         1493  +				elseif cp == 0x5d then
         1494  +					if d >= 1 then
         1495  +						d = d - 1
         1496  +					else
         1497  +						table.insert(r, 0x5c)
         1498  +					end
   939   1499   				end
         1500  +				table.insert(r, cp)
   940   1501   			end
   941         -			table.insert(r, cp)
   942   1502   		end
   943   1503   		return '[' .. utf8.char(table.unpack(r)) .. ']'
   944   1504   	end
   945         -	local function o(...) io.stdout:write(string.format(...)..'\n') end
         1505  +	local function o(lvl,...)
         1506  +		local pfx = ''
         1507  +		if ctx.flags.human and lvl > 0 then
         1508  +			pfx = string.rep('\t', lvl)
         1509  +		end
         1510  +		ofd:write(pfx..string.format(...)..'\n')
         1511  +	end
   946   1512   	local d = ctx.dict
   947         -	o('pv0 %s %s', san(d.header.lang), san(d.header.meta))
         1513  +	o(0,'pv0 %s %s', san(d.header.lang), san(d.header.meta))
         1514  +	local function checksyn(obj)
         1515  +-- 		for _,s in ipairs(d.synonyms) do
         1516  +		local lvl = 0
         1517  +		if     obj.nn then lvl = 4
         1518  +		elseif obj.mn then lvl = 3
         1519  +		elseif obj.dn then lvl = 2
         1520  +		elseif obj.w  then lvl = 1 end
         1521  +		if not d._relCache[obj.w] then return end
         1522  +		for _,s in ipairs(d._relCache[obj.w]) do
         1523  +			for _,sm in ipairs(s.members) do
         1524  +				if pathMatch(obj, sm) then
         1525  +					o(lvl,'r %u',s.uid)
         1526  +					break
         1527  +				end
         1528  +			end
         1529  +		end
         1530  +	end
         1531  +	for i,f in pairs(d.header.inflectionForms) do
         1532  +		o(0,'f %s %s %s', san(f.name), san(f.abbrev), san(f.desc))
         1533  +		for j,p in pairs(f.parts) do
         1534  +			o(1,'fp %s', san(p))
         1535  +		end
         1536  +	end
   948   1537   	for lit, w in pairs(d.words) do
   949         -		o('w %s',san(lit))
         1538  +		o(0,'w %s',san(lit))
         1539  +		checksyn{w=lit}
   950   1540   		for i,def in ipairs(w.defs) do
   951         -			o('d %s',san(def.part))
   952         -			for _,s in ipairs(d.synonyms) do
   953         -				for _,sm in ipairs(s.members) do
   954         -					if sm.word == w and sm.def == i then
   955         -						o('ds %u',s.uid)
   956         -						break
   957         -					end
   958         -				end
   959         -			end
         1541  +			o(1,'d %s',san(def.part))
         1542  +			checksyn{w=lit,dn=i}
   960   1543   			for j,r in ipairs(def.branch) do
   961         -				o('dr %s',san(r))
         1544  +				o(2,'dr %s',san(r))
   962   1545   			end
   963   1546   			for j,m in ipairs(def.means) do
   964         -				o('m %s', san(m.lit))
         1547  +				o(2,'m %s', san(m.lit))
         1548  +				checksyn{w=lit,dn=i,mn=j}
   965   1549   				for k,n in ipairs(m.notes) do
   966         -					o('n %s', san(n.kind))
         1550  +					o(3,'n %s', san(n.kind))
   967   1551   					for a,p in ipairs(n.paras) do
   968         -						o('np %s', san(p))
         1552  +						o(4,'np %s', san(p))
   969   1553   					end
   970   1554   				end
   971   1555   			end
   972   1556   		end
   973   1557   	end
   974         -	for _,s in ipairs(d.synonyms) do o('s %u', s.uid) end
         1558  +	for _,s in ipairs(d.relsets) do o(0,'s %s %u', s.kind, s.uid) end
         1559  +end
         1560  +
         1561  +local function filterD(lst, fn)
         1562  +-- cheap algorithm to destructively filter a list
         1563  +-- DOES NOT preserve order!!
         1564  +	local top = #lst
         1565  +	for i=top,1,-1 do local m = lst[i]
         1566  +		if not fn(m,i) then
         1567  +			lst[i] = lst[top]
         1568  +			lst[top] = nil
         1569  +			top = top - 1
         1570  +		end
         1571  +	end
         1572  +	return lst
   975   1573   end
   976   1574   
   977   1575   function cmds.mod.exec(ctx, orig, oper, dest, ...)
   978   1576   	if (not orig) or not oper then
   979   1577   		id10t '`mod` requires at least an origin and an operation'
   980   1578   	end
   981         -	local op, dp = parsePath(orig)
         1579  +	local op, dp = pathParse(orig)
   982   1580   	local w,d,m,n = safeNavWord(ctx, op.w,op.dn,op.mn,op.nn)
         1581  +	-- unfortunately, "pointers" exist elsewhere in the
         1582  +	-- structure, currently just from relsets, that must
         1583  +	-- be updated whenever an object moves or changes.
         1584  +	-- this is miserable and takes a lot of work, using
         1585  +	-- algorithms provided by the following functions.
         1586  +	-- note that we don't bother trying to update the
         1587  +	-- relcache as we go, it's simply not worth the work;
         1588  +	-- instead we simply rebuild the whole cache when
         1589  +	-- this command returns
         1590  +	local function cleanupRels(path, fn)
         1591  +		local rc = ctx.dict._relCache[path.w]
         1592  +		if rc then
         1593  +			for k,s in pairs(rc) do fn(s,k) end
         1594  +		end
         1595  +	end
         1596  +	local function cleanupRelsEach(path, fn)
         1597  +		cleanupRels(path, function(s,k)
         1598  +			local top = #s.members
         1599  +			for i=1,top do local m=s.members[i]
         1600  +				if pathSub(path, m) then
         1601  +					local val = fn(m,s,i)
         1602  +					if val ~= nil then
         1603  +						s.members[i] = val
         1604  +					end
         1605  +				end
         1606  +			end
         1607  +		end)
         1608  +	end
         1609  +	local function deleteRefsTo(path)
         1610  +		cleanupRels(path, function(s)
         1611  +		-- antonyms: delete the headword and transform the group
         1612  +		-- to a list of synonyms
         1613  +			if s.kind == 'ant' and pathSub(path,s.members[1]) then
         1614  +				s.kind = 'syn'
         1615  +			end
         1616  +			filterD(s.members, function(m)
         1617  +				return not pathSub(path, m)
         1618  +			end)
         1619  +		end)
         1620  +		if not path.dn then
         1621  +			ctx.dict._relCache[path.w] = nil
         1622  +		end
         1623  +	end
         1624  +	local function moveRelTree(op,dp)
         1625  +		cleanupRelsEach(op, function(old,set,idx)
         1626  +			local new = {}
         1627  +			for _,elt in pairs{'w','dn','mn','nn'} do
         1628  +				if dp[elt] ~= nil then
         1629  +					new[elt] = dp[elt]
         1630  +				else
         1631  +					new[elt] = op[elt] or old[elt]
         1632  +				end
         1633  +			end
         1634  +			return new
         1635  +		end)
         1636  +	end
         1637  +	local function shiftRelTree(dp, fld, nid, amt)
         1638  +		local cleanupTargetMask = ({
         1639  +			dn = {w=dp.w};
         1640  +			mn = {w=dp.w,dn=dp.dn};
         1641  +			nn = {w=dp.w,dn=dp.dn,mn=dp.mn};
         1642  +		})[fld] -- >____<
         1643  +		cleanupRelsEach(cleanupTargetMask, function(old,set,i)
         1644  +			if old[fld] >= nid then
         1645  +				old[fld] = old[fld] + amt
         1646  +			end
         1647  +		end)
         1648  +	end
         1649  +	local function insertAndMoveRelTree(tbl,n,op,dp,fld)
         1650  +		local nid = #tbl
         1651  +		local path = copy(dp)
         1652  +		path[fld] = nid
         1653  +		tbl[nid] = n
         1654  +		shiftRelTree(dp,fld,1)
         1655  +		moveRelTree(op, path)
         1656  +	end
   983   1657   	if oper == 'drop' then
         1658  +		-- clean out the cache and delete relationships
         1659  +		deleteRefsTo(op)
   984   1660   		if not d then
   985   1661   			ctx.dict.words[op.w] = nil
   986   1662   		elseif not m then
   987   1663   			table.remove(w.defs, op.dn)
   988   1664   		elseif not n then
   989   1665   			table.remove(d.means, op.mn)
   990   1666   		else
   991   1667   			table.remove(m.notes, op.nn)
   992   1668   		end
   993   1669   	elseif oper == 'out' then
   994         -		if n or not m then
   995         -			id10t '`mod out` must target a meaning'
   996         -		end
         1670  +		if n or not m then id10t '`mod out` must target a meaning' end
   997   1671   		if not dest then id10t '`mod out` requires at least a part of speech' end
   998   1672   		local newdef = {
   999   1673   			part = dest;
  1000   1674   			branch = {...};
  1001   1675   			forms = {};
  1002   1676   			means = {m};
  1003   1677   		}
         1678  +		shiftRelTree(op, 'dn', op.dn, 1)
  1004   1679   		table.insert(w.defs,op.dn+1, newdef)
         1680  +		moveRelTree(op,{w=op.w, dn=op.dn+1, mn=1})
  1005   1681   		table.remove(d.means,op.mn)
  1006   1682   	elseif oper == 'move' or oper == 'merge' or oper == 'clobber' then
  1007   1683   		if dest
  1008         -			then dp = parsePath(dest)
         1684  +			then dp = pathParse(dest)
  1009   1685   			else id10t('`mod %s` requires a target',oper)
  1010   1686   		end
  1011   1687   		if n then
  1012   1688   			if not dp.mn then
  1013   1689   				id10t '`mod` on a note requires a note or meaning destination'
  1014   1690   			end
  1015   1691   			local _,_,dm = safeNavWord(ctx, dp.w,dp.dn,dp.mn)
  1016   1692   			if dp.nn then
  1017   1693   				if oper == 'move' then
         1694  +					shiftRelTree(dp, 'nn', dp.nn, 1)
  1018   1695   					table.insert(dm.notes, dp.nn, n)
  1019   1696   				elseif oper == 'merge' then
  1020   1697   					local top = #(dm.notes[dp.nn].paras)
  1021   1698   					for i, v in ipairs(n.paras) do
  1022   1699   						dm.notes[dp.nn].paras[i+top] = v
  1023   1700   					end
  1024   1701   				elseif oper == 'clobber' then
         1702  +					deleteRefsTo(dp)
  1025   1703   					dm.notes[dp.nn] = n
  1026   1704   				end
         1705  +				moveRelTree(op,dp)
  1027   1706   			else
  1028   1707   				if oper ~= 'move' then
  1029   1708   					id10t('`mod note %s` requires a note target', oper)
  1030   1709   				end
  1031         -				table.insert(dm.notes, n)
         1710  +				insertAndMoveRelTree(dm.notes,n,op,dp,'nn')
  1032   1711   			end
  1033   1712   			if oper == 'move' and dp.nn and dm == m and op.nn > dp.nn then
  1034   1713   				table.remove(m.notes,op.nn+1)
  1035   1714   			else
  1036   1715   				table.remove(m.notes,op.nn)
  1037   1716   			end
  1038   1717   		elseif m then
................................................................................
  1039   1718   			if not dp.dn then
  1040   1719   				local newdef = {
  1041   1720   					part = d.part;
  1042   1721   					branch = copy(d.branch);
  1043   1722   					forms = copy(d.forms);
  1044   1723   					means = {m};
  1045   1724   				}
         1725  +				local didx
  1046   1726   				if ctx.dict.words[dp.w] then
  1047         -					table.insert(ctx.dict.words[dp.w].defs, newdef)
         1727  +					local defst = ctx.dict.words[dp.w].defs
         1728  +					didx = #defst
         1729  +					defst[didx] = newdef
  1048   1730   				else
  1049   1731   					ctx.dict.words[dp.w] = {
  1050   1732   						defs = {newdef};
  1051   1733   					}
         1734  +					didx = 1
  1052   1735   				end
         1736  +				cleanupRelsEach(op, function(oldpath,set,mi)
         1737  +					return {w=dp.w,dn=didx,mn=1,nn=oldpath.nn}
         1738  +				end)
  1053   1739   				table.remove(d.means,dp.mn)
  1054   1740   			else
  1055   1741   				local dw, dd = safeNavWord(ctx, dp.w, dp.dn)
  1056   1742   				if dp.mn then
  1057   1743   					if dd.means[dp.mn] and (oper == 'merge' or oper=='clobber') then
  1058   1744   						if oper == 'merge' then
  1059   1745   							dd.means[dp.mn] = dd.means[dp.mn] .. '; ' .. m
  1060   1746   						elseif oper == 'clobber' then
         1747  +							deleteRefsTo(dp)
  1061   1748   							dd.means[dp.mn] = m
  1062   1749   						end
  1063   1750   					else
  1064         -						if oper == clobber then dd.means = {} end
         1751  +						cleanupRelsEach({w=dp.w,dn=dp.dn}, function(old,set,i)
         1752  +							if old.mn >= dp.mn then
         1753  +								old.mn = old.mn + 1
         1754  +							end
         1755  +						end)
  1065   1756   						table.insert(dd.means, dp.mn, m)
  1066   1757   					end
         1758  +					moveRelTree(op,dp)
  1067   1759   				else
  1068         -					table.insert(dd.means, m)
         1760  +					insertAndMoveRelTree(dd.means,m, op,dp,'mn')
         1761  +-- 					table.insert(dd.means, m)
  1069   1762   				end
  1070   1763   				if oper == 'move' and dp.mn and dd.means == d.means and op.mn > dp.mn then
  1071   1764   					table.remove(d.means,op.mn+1)
  1072   1765   				else
  1073   1766   					table.remove(d.means,op.mn)
  1074   1767   				end
  1075   1768   			end
................................................................................
  1078   1771   			if dp.dn then
  1079   1772   				if oper == 'merge' then
  1080   1773   					local top = #(ddefs[dp.dn].means)
  1081   1774   					for i,om in ipairs(d.means) do
  1082   1775   						ddefs[dp.dn].means[top+i] = om
  1083   1776   					end
  1084   1777   					for k,p in pairs(d.forms) do
         1778  +						deleteRefsTo(dp)
  1085   1779   						ddefs[dp.dn].forms[k] = p -- clobbers!
  1086   1780   					end
  1087   1781   				else
         1782  +					shiftRelTree(dp,'dn',dp.dn,1)
  1088   1783   					table.insert(ddefs, dp.dn, d)
  1089   1784   				end
         1785  +				moveRelTree(op,dp)
  1090   1786   			else
  1091         -				table.insert(ddefs, d)
         1787  +				insertAndMoveRelTree(ddefs,d, op,dp,'dn')
         1788  +-- 				table.insert(ddefs, d)
  1092   1789   			end
  1093   1790   			if oper == 'move' and dp.mn and w.defs == ddefs and op.mn > dp.mn then
  1094   1791   				table.remove(w.defs,op.dn+1)
  1095   1792   			else
  1096   1793   				table.remove(w.defs,op.dn)
  1097   1794   			end
  1098   1795   		else
  1099   1796   			if ctx.dict.words[dp.w] then
  1100   1797   				if oper ~= 'merge' then
  1101   1798   					id10t('the word “%s” already exists; use `merge` if you want to merge the words together', dp.w)
  1102   1799   				end
  1103   1800   				for i,def in ipairs(w.defs) do
         1801  +					local odp = copy(op) odp.dn = i
         1802  +					local ddp = {w=dp.w, dn=dp.dn+i-1}
  1104   1803   					if dp.dn then
         1804  +						shiftRelTree(dp, 'dn', dp.dn+i-1, 1)
  1105   1805   						table.insert(ctx.dict.words[dp.w].defs, dp.dn+i-1, def)
         1806  +						moveRelTree(odp,ddp)
  1106   1807   					else
  1107         -						table.insert(ctx.dict.words[dp.w].defs, def)
         1808  +-- 						table.insert(ctx.dict.words[dp.w].defs, def)
         1809  +						insertAndMoveRelTree(ctx.dict.words[dp.w].defs, def,
         1810  +													odp,dp,'dn')
  1108   1811   					end
  1109   1812   				end
  1110   1813   			else
  1111   1814   				ctx.dict.words[dp.w] = w
         1815  +				moveRelTree(op,dp)
         1816  +-- 				ctx.dict._relCache[dp.w] = ctx.dict._relCache[op.w]
         1817  +-- 				ctx.dict._relCache[op.w] = nil
  1112   1818   			end
  1113   1819   			ctx.dict.words[op.w] = nil
  1114   1820   		end
  1115   1821   	end
         1822  +	rebuildRelationCache(ctx.dict)
  1116   1823   end
  1117   1824   
  1118   1825   local function fileLegible(file)
  1119   1826   	-- check if we can access the file
  1120   1827   	local fd = io.open(file,"rb")
  1121   1828   	local ret = false
  1122   1829   	if fd then ret = true end
  1123   1830   	fd:close()
  1124   1831   	return ret
  1125   1832   end
  1126   1833   
  1127         -local function map(fn,lst)
  1128         -	local new = {}
  1129         -	for k,v in pairs(lst) do
  1130         -		local nv, nk = fn(v,k)
  1131         -		new[nk or k] = nv
  1132         -	end
  1133         -	return new
  1134         -end
  1135         -local function mapD(fn,lst) --destructive
  1136         -	-- WARNING: this will not work if nk names an existing key!
  1137         -	for k,v in pairs(lst) do
  1138         -		local nv, nk = fn(v,k)
  1139         -		if nk == nil or k == nk then
  1140         -			lst[k] = nv
  1141         -		else
  1142         -			lst[k] = nil
  1143         -			lst[nk] = nv
  1144         -		end
  1145         -	end
  1146         -	return lst
  1147         -end
  1148   1834   
  1149   1835   local function
  1150   1836   prompt(p,multiline)
  1151   1837   	-- returns string if successful, nil if EOF, false if ^C
  1152   1838   	io.stderr:write(p)
  1153   1839   	local ok, res = pcall(function()
  1154   1840   	                      return io.stdin:read(multiline and 'a' or 'l')
................................................................................
  1199   1885   				local c = cmds[words[1]]
  1200   1886   				if c then
  1201   1887   					if c.raw then
  1202   1888   						ctx.log('fatal', words[1] .. ' cannot be run from `shell`')
  1203   1889   					elseif not implies(c.write, rw) then
  1204   1890   						ctx.log('fatal', ctx.file .. ' is not writable')
  1205   1891   					else
  1206         -						local ok = ctx.try(c.exec, ctx, table.unpack(words,2))
  1207         -						if ok then written = written or c.write end
         1892  +						local ok, outcome = ctx.try(c.exec, ctx, table.unpack(words,2))
         1893  +						if ok and outcome ~= false then written = written or c.write end
  1208   1894   					end
  1209   1895   				elseif cmd == 'save' or cmd == 'wq' then
  1210   1896   					if not written then
  1211   1897   						ctx.log('info', 'no changes to save')
  1212   1898   					end
  1213   1899   					cmd = nil
  1214   1900   				elseif cmd == 'quit' or cmd == 'q' then
................................................................................
  1261   1947   		showHelp(ctx, cmd, cmds[cmd])
  1262   1948   	else
  1263   1949   		for cmd,c in pairs(cmds) do
  1264   1950   			showHelp(ctx, cmd, c)
  1265   1951   		end
  1266   1952   	end
  1267   1953   end
         1954  +
         1955  +local globalFlags <const> = {
         1956  +	human = {'h','human','enable human-readable exports'};
         1957  +	ident = {'i','ident','show identifier paths for all items'}
         1958  +}
  1268   1959   
  1269   1960   local function
  1270   1961   usage(me,ctx)
  1271   1962   	local ln = 0
  1272   1963   	local ct = {}
  1273   1964   	local fe = ctx.sty[io.stderr]
  1274         -	io.stderr:write(string.format(fe.br"usage:".." %s <file> [<command> [args…]]\n",me))
         1965  +	local fstr = ""
         1966  +	local flagHelp = {}
         1967  +	for k,v in pairs(globalFlags) do
         1968  +		fstr = fstr .. v[1]
         1969  +		table.insert(flagHelp, string.format("  -%s --%s: %s\n",table.unpack(v)))
         1970  +	end
         1971  +	io.stderr:write(string.format(fe.br"usage:".." %s [-%s] <file> [<command> [args…]]\n",me,fstr) .. table.concat(flagHelp))
  1275   1972   	--[[
  1276   1973   	for k,v in pairs(cmds) do
  1277   1974   		local n = 1 + utf8.len(k) + utf8.len(v.syntax)
  1278   1975   		ct[k] = n
  1279   1976   		if n > ln then ln = n end
  1280   1977   	end
  1281   1978   	for k,v in pairs(cmds) do
................................................................................
  1288   1985   	end
  1289   1986   	return 64
  1290   1987   end
  1291   1988   
  1292   1989   local function
  1293   1990   dispatch(argv, ctx)
  1294   1991   	local ferr = ctx.sty[io.stderr]
  1295         -	local file, cmd = table.unpack(argv)
         1992  +	local args = {}
         1993  +	local flags = {}
         1994  +	local i = 1 while i <= #argv do
         1995  +		local a = argv[i]
         1996  +		if a == '--' then i=i+1 break
         1997  +		elseif a:sub(1,2) == '--' then
         1998  +			local fs <const> = a:sub(3)
         1999  +			for k,v in pairs(globalFlags) do
         2000  +				if v[2] == fs then flags[k] = true end
         2001  +			end
         2002  +		elseif a:sub(1,1) == '-' then
         2003  +			for p,cp in utf8.codes(''), a, #'-' do
         2004  +				local c <const> = utf8.char(cp)
         2005  +				for k,v in pairs(globalFlags) do
         2006  +					if v[1] == c then flags[k] = true break end
         2007  +				end
         2008  +			end
         2009  +		else table.insert(args, a) end
         2010  +	i = i + 1 end
         2011  +	for j=i,#argv do table.insert(args,argv[j]) end
         2012  +
         2013  +	local file, cmd = table.unpack(args)
  1296   2014   	if cmd and cmds[cmd] then
  1297   2015   		local c,fd,dict = cmds[cmd]
  1298   2016   		if (not c.raw) and not c.nofile then
  1299   2017   			fd = safeopen(file, "rb")
  1300   2018   			dict = readDict(fd:read 'a')
  1301   2019   			fd:close()
  1302   2020   			-- lua io has no truncate method, so we must
................................................................................
  1305   2023   		end
  1306   2024   
  1307   2025   		cmds[cmd].exec({
  1308   2026   			sty = ctx.sty;
  1309   2027   			try = ctx.try;
  1310   2028   			log = ctx.log;
  1311   2029   
         2030  +			flags = flags;
  1312   2031   			file = file;
  1313   2032   			fd = fd;
  1314   2033   			dict = dict;
  1315         -		}, table.unpack(argv,3))
         2034  +		}, table.unpack(args,3))
  1316   2035   
  1317   2036   		if (not c.raw) and c.write then
  1318   2037   			local output = writeDict(dict)
  1319   2038   			-- writeDict should always be given a chance to
  1320   2039   			-- bail before the previous file is destroyed!!
  1321   2040   			-- you don't want one bug to wipe out your entire
  1322   2041   			-- dictionary in one fell swoop
................................................................................
  1344   2063   
  1345   2064   local function log(lvl, msg)
  1346   2065   	local colors = {fatal=1,warn=3,info=4,debug=2}
  1347   2066   	local ferr = sty[io.stderr]
  1348   2067   	io.stderr:write(string.format(
  1349   2068   		ferr.color(ferr.br("(%s)"),colors[lvl]).." %s\n", lvl, msg))
  1350   2069   end
  1351         -local function try(...)
         2070  +
         2071  +local function stacktrace(err)
         2072  +	return debug.traceback(err,3)
         2073  +end
         2074  +
         2075  +local function try(fn,...)
  1352   2076   	-- a wrapper around pcall that produces a standard error
  1353   2077   	-- message format when an error occurs
  1354         -	local res = { pcall(...) }
         2078  +	local res = { xpcall(fn,stacktrace,...) }
  1355   2079   	if not res[1] then
  1356   2080   		log('fatal', res[2])
  1357   2081   	end
  1358   2082   	return table.unpack(res)
  1359   2083   end
  1360   2084   
  1361         -local function stacktrace(err)
  1362         -	return debug.traceback(err,3)
  1363         -end
  1364   2085   local ok, res = xpcall(dispatch, stacktrace, argv, {
  1365   2086   	try = try, sty = sty, log = log
  1366   2087   })
  1367   2088   
  1368   2089   if not ok then
  1369   2090   	log('fatal', res)
  1370   2091   	os.exit(1)
  1371   2092   end
  1372   2093   
  1373   2094   os.exit(res)