buninu 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
  3. package/apps/jsgotty/NOTICE +5 -0
  4. package/apps/jsgotty/README.en.md +21 -0
  5. package/apps/jsgotty/README.md +21 -0
  6. package/apps/jsgotty/client-modes.mjs +117 -0
  7. package/apps/jsgotty/client.mjs +100 -10
  8. package/apps/jsgotty/clipboard-powershell.js +18 -0
  9. package/apps/jsgotty/clipboard.js +148 -0
  10. package/apps/jsgotty/gotty.js +13 -0
  11. package/apps/jsgotty/package-lock.json +2 -2
  12. package/apps/jsgotty/package.json +1 -1
  13. package/apps/jsgotty/static/css/index.css +14 -1
  14. package/apps/jsgotty/static/js/gotty.js +107 -8
  15. package/apps/jsgotty/static/js/kitty.js +115 -7
  16. package/apps/jsgotty/terminal-modes.js +332 -0
  17. package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
  18. package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
  19. package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
  20. package/apps/jsgotty/tests/clipboard.test.js +75 -0
  21. package/apps/jsgotty/tests/manual/zoom-collector.cjs +55 -0
  22. package/apps/jsgotty/tests/manual/zoom-debug.js +39 -0
  23. package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
  24. package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
  25. package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
  26. package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
  27. package/apps/jsgotty/tests/reconnect-modes.md +44 -0
  28. package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
  29. package/apps/jsgotty/tests/touch-drag-browser.test.js +106 -0
  30. package/apps/jsgotty/tests/wheel-browser.test.js +60 -0
  31. package/apps/jsmdcui/CHANGELOG.md +44 -0
  32. package/apps/jsmdcui/package.json +1 -1
  33. package/apps/jsmdcui/src/highlight/go-regexp.js +83 -0
  34. package/apps/jsmdcui/src/highlight/highlighter.js +49 -62
  35. package/apps/jsmdcui/src/highlight/parser.js +8 -5
  36. package/apps/jsmdcui/tests/go-regexp.test.js +98 -0
  37. package/apps/jsmdcui/tests/heading-ids.test.js +21 -16
  38. package/apps/jsmdcui/tests/highlight-regions.test.js +73 -0
  39. package/apps/jsmdcui/tests/id-collision.test.js +32 -9
  40. package/apps/jsmdcui/tests/markdown-behavior.mjs +9 -0
  41. package/apps/jsmdcui/tests/syntax-parity/NORMAL-HTML.md +46 -0
  42. package/apps/jsmdcui/tests/syntax-parity/README.md +85 -0
  43. package/apps/jsmdcui/tests/syntax-parity/REPORT.md +328 -0
  44. package/apps/jsmdcui/tests/syntax-parity/compare.mjs +75 -0
  45. package/apps/jsmdcui/tests/syntax-parity/go.mod +5 -0
  46. package/apps/jsmdcui/tests/syntax-parity/normal-html-report.json +619 -0
  47. package/apps/jsmdcui/tests/syntax-parity/normal-html.html +9 -0
  48. package/apps/jsmdcui/tests/syntax-parity/oracle.go +49 -0
  49. package/apps/jsmdcui/tests/syntax-parity/report.json +2174 -0
  50. package/apps/jsmdcui/tests/syntax-parity/samples/PowerShell.txt +39 -0
  51. package/apps/jsmdcui/tests/syntax-parity/samples/ada.txt +25 -0
  52. package/apps/jsmdcui/tests/syntax-parity/samples/apacheconf.txt +49 -0
  53. package/apps/jsmdcui/tests/syntax-parity/samples/arduino.txt +38 -0
  54. package/apps/jsmdcui/tests/syntax-parity/samples/asciidoc.txt +33 -0
  55. package/apps/jsmdcui/tests/syntax-parity/samples/asm.txt +77 -0
  56. package/apps/jsmdcui/tests/syntax-parity/samples/ats.txt +48 -0
  57. package/apps/jsmdcui/tests/syntax-parity/samples/awk.txt +28 -0
  58. package/apps/jsmdcui/tests/syntax-parity/samples/b.txt +27 -0
  59. package/apps/jsmdcui/tests/syntax-parity/samples/bat.txt +39 -0
  60. package/apps/jsmdcui/tests/syntax-parity/samples/c.txt +34 -0
  61. package/apps/jsmdcui/tests/syntax-parity/samples/caddyfile.txt +14 -0
  62. package/apps/jsmdcui/tests/syntax-parity/samples/cake.txt +7 -0
  63. package/apps/jsmdcui/tests/syntax-parity/samples/clojure.txt +18 -0
  64. package/apps/jsmdcui/tests/syntax-parity/samples/cmake.txt +20 -0
  65. package/apps/jsmdcui/tests/syntax-parity/samples/coffeescript.txt +32 -0
  66. package/apps/jsmdcui/tests/syntax-parity/samples/colortest.txt +19 -0
  67. package/apps/jsmdcui/tests/syntax-parity/samples/conky.txt +12 -0
  68. package/apps/jsmdcui/tests/syntax-parity/samples/cpp.txt +46 -0
  69. package/apps/jsmdcui/tests/syntax-parity/samples/crontab.txt +16 -0
  70. package/apps/jsmdcui/tests/syntax-parity/samples/crystal.txt +28 -0
  71. package/apps/jsmdcui/tests/syntax-parity/samples/csharp.txt +28 -0
  72. package/apps/jsmdcui/tests/syntax-parity/samples/css.txt +19 -0
  73. package/apps/jsmdcui/tests/syntax-parity/samples/csx.txt +7 -0
  74. package/apps/jsmdcui/tests/syntax-parity/samples/cuda.txt +37 -0
  75. package/apps/jsmdcui/tests/syntax-parity/samples/cython.txt +22 -0
  76. package/apps/jsmdcui/tests/syntax-parity/samples/d.txt +52 -0
  77. package/apps/jsmdcui/tests/syntax-parity/samples/dart.txt +27 -0
  78. package/apps/jsmdcui/tests/syntax-parity/samples/default.txt +8 -0
  79. package/apps/jsmdcui/tests/syntax-parity/samples/dockerfile.txt +10 -0
  80. package/apps/jsmdcui/tests/syntax-parity/samples/dot.txt +15 -0
  81. package/apps/jsmdcui/tests/syntax-parity/samples/elixir.txt +23 -0
  82. package/apps/jsmdcui/tests/syntax-parity/samples/elm.txt +20 -0
  83. package/apps/jsmdcui/tests/syntax-parity/samples/erb.txt +32 -0
  84. package/apps/jsmdcui/tests/syntax-parity/samples/erlang.txt +23 -0
  85. package/apps/jsmdcui/tests/syntax-parity/samples/fish.txt +20 -0
  86. package/apps/jsmdcui/tests/syntax-parity/samples/forth.txt +17 -0
  87. package/apps/jsmdcui/tests/syntax-parity/samples/fortran.txt +40 -0
  88. package/apps/jsmdcui/tests/syntax-parity/samples/freebsd-kernel.txt +10 -0
  89. package/apps/jsmdcui/tests/syntax-parity/samples/fsharp.txt +27 -0
  90. package/apps/jsmdcui/tests/syntax-parity/samples/gdscript.txt +26 -0
  91. package/apps/jsmdcui/tests/syntax-parity/samples/gemini.txt +11 -0
  92. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-ebuild.txt +28 -0
  93. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-etc-portage.txt +14 -0
  94. package/apps/jsmdcui/tests/syntax-parity/samples/git-commit.txt +19 -0
  95. package/apps/jsmdcui/tests/syntax-parity/samples/git-config.txt +11 -0
  96. package/apps/jsmdcui/tests/syntax-parity/samples/git-rebase-todo.txt +10 -0
  97. package/apps/jsmdcui/tests/syntax-parity/samples/gleam.txt +44 -0
  98. package/apps/jsmdcui/tests/syntax-parity/samples/glsl.txt +18 -0
  99. package/apps/jsmdcui/tests/syntax-parity/samples/gnuplot.txt +11 -0
  100. package/apps/jsmdcui/tests/syntax-parity/samples/go.txt +33 -0
  101. package/apps/jsmdcui/tests/syntax-parity/samples/godoc.txt +9 -0
  102. package/apps/jsmdcui/tests/syntax-parity/samples/golo.txt +50 -0
  103. package/apps/jsmdcui/tests/syntax-parity/samples/gomod.txt +16 -0
  104. package/apps/jsmdcui/tests/syntax-parity/samples/graphql.txt +20 -0
  105. package/apps/jsmdcui/tests/syntax-parity/samples/groff.txt +18 -0
  106. package/apps/jsmdcui/tests/syntax-parity/samples/groovy.txt +42 -0
  107. package/apps/jsmdcui/tests/syntax-parity/samples/haml.txt +16 -0
  108. package/apps/jsmdcui/tests/syntax-parity/samples/hare.txt +31 -0
  109. package/apps/jsmdcui/tests/syntax-parity/samples/haskell.txt +26 -0
  110. package/apps/jsmdcui/tests/syntax-parity/samples/hc.txt +28 -0
  111. package/apps/jsmdcui/tests/syntax-parity/samples/html.txt +26 -0
  112. package/apps/jsmdcui/tests/syntax-parity/samples/html4.txt +20 -0
  113. package/apps/jsmdcui/tests/syntax-parity/samples/html5.txt +20 -0
  114. package/apps/jsmdcui/tests/syntax-parity/samples/ini.txt +16 -0
  115. package/apps/jsmdcui/tests/syntax-parity/samples/inputrc.txt +14 -0
  116. package/apps/jsmdcui/tests/syntax-parity/samples/java.txt +18 -0
  117. package/apps/jsmdcui/tests/syntax-parity/samples/javascript.txt +41 -0
  118. package/apps/jsmdcui/tests/syntax-parity/samples/jinja2.txt +13 -0
  119. package/apps/jsmdcui/tests/syntax-parity/samples/json.txt +21 -0
  120. package/apps/jsmdcui/tests/syntax-parity/samples/jsonnet.txt +28 -0
  121. package/apps/jsmdcui/tests/syntax-parity/samples/julia.txt +24 -0
  122. package/apps/jsmdcui/tests/syntax-parity/samples/justfile.txt +26 -0
  123. package/apps/jsmdcui/tests/syntax-parity/samples/keymap.txt +15 -0
  124. package/apps/jsmdcui/tests/syntax-parity/samples/kickstart.txt +16 -0
  125. package/apps/jsmdcui/tests/syntax-parity/samples/kotlin.txt +30 -0
  126. package/apps/jsmdcui/tests/syntax-parity/samples/kvlang.txt +37 -0
  127. package/apps/jsmdcui/tests/syntax-parity/samples/ledger.txt +13 -0
  128. package/apps/jsmdcui/tests/syntax-parity/samples/lfe.txt +17 -0
  129. package/apps/jsmdcui/tests/syntax-parity/samples/lilypond.txt +15 -0
  130. package/apps/jsmdcui/tests/syntax-parity/samples/lisp.txt +17 -0
  131. package/apps/jsmdcui/tests/syntax-parity/samples/log.txt +45 -0
  132. package/apps/jsmdcui/tests/syntax-parity/samples/lua.txt +48 -0
  133. package/apps/jsmdcui/tests/syntax-parity/samples/mail.txt +17 -0
  134. package/apps/jsmdcui/tests/syntax-parity/samples/makefile.txt +25 -0
  135. package/apps/jsmdcui/tests/syntax-parity/samples/man.txt +12 -0
  136. package/apps/jsmdcui/tests/syntax-parity/samples/markdown.txt +21 -0
  137. package/apps/jsmdcui/tests/syntax-parity/samples/mc.txt +11 -0
  138. package/apps/jsmdcui/tests/syntax-parity/samples/meson.txt +29 -0
  139. package/apps/jsmdcui/tests/syntax-parity/samples/micro.txt +25 -0
  140. package/apps/jsmdcui/tests/syntax-parity/samples/mpdconf.txt +13 -0
  141. package/apps/jsmdcui/tests/syntax-parity/samples/msbuild.txt +7 -0
  142. package/apps/jsmdcui/tests/syntax-parity/samples/nanorc.txt +16 -0
  143. package/apps/jsmdcui/tests/syntax-parity/samples/nftables.txt +22 -0
  144. package/apps/jsmdcui/tests/syntax-parity/samples/nginx.txt +17 -0
  145. package/apps/jsmdcui/tests/syntax-parity/samples/nim.txt +23 -0
  146. package/apps/jsmdcui/tests/syntax-parity/samples/nix.txt +15 -0
  147. package/apps/jsmdcui/tests/syntax-parity/samples/nu.txt +92 -0
  148. package/apps/jsmdcui/tests/syntax-parity/samples/objc.txt +36 -0
  149. package/apps/jsmdcui/tests/syntax-parity/samples/ocaml.txt +25 -0
  150. package/apps/jsmdcui/tests/syntax-parity/samples/octave.txt +35 -0
  151. package/apps/jsmdcui/tests/syntax-parity/samples/odin.txt +35 -0
  152. package/apps/jsmdcui/tests/syntax-parity/samples/pascal.txt +22 -0
  153. package/apps/jsmdcui/tests/syntax-parity/samples/patch.txt +13 -0
  154. package/apps/jsmdcui/tests/syntax-parity/samples/peg.txt +16 -0
  155. package/apps/jsmdcui/tests/syntax-parity/samples/perl.txt +27 -0
  156. package/apps/jsmdcui/tests/syntax-parity/samples/php.txt +44 -0
  157. package/apps/jsmdcui/tests/syntax-parity/samples/pkg-config.txt +12 -0
  158. package/apps/jsmdcui/tests/syntax-parity/samples/po.txt +12 -0
  159. package/apps/jsmdcui/tests/syntax-parity/samples/pony.txt +29 -0
  160. package/apps/jsmdcui/tests/syntax-parity/samples/pov.txt +17 -0
  161. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-action.txt +14 -0
  162. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-config.txt +10 -0
  163. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-filter.txt +12 -0
  164. package/apps/jsmdcui/tests/syntax-parity/samples/proto.txt +22 -0
  165. package/apps/jsmdcui/tests/syntax-parity/samples/prql.txt +42 -0
  166. package/apps/jsmdcui/tests/syntax-parity/samples/puppet.txt +22 -0
  167. package/apps/jsmdcui/tests/syntax-parity/samples/python2.txt +24 -0
  168. package/apps/jsmdcui/tests/syntax-parity/samples/python3.txt +28 -0
  169. package/apps/jsmdcui/tests/syntax-parity/samples/r.txt +16 -0
  170. package/apps/jsmdcui/tests/syntax-parity/samples/raku.txt +19 -0
  171. package/apps/jsmdcui/tests/syntax-parity/samples/reST.txt +18 -0
  172. package/apps/jsmdcui/tests/syntax-parity/samples/renpy.txt +11 -0
  173. package/apps/jsmdcui/tests/syntax-parity/samples/rpmspec.txt +31 -0
  174. package/apps/jsmdcui/tests/syntax-parity/samples/ruby.txt +34 -0
  175. package/apps/jsmdcui/tests/syntax-parity/samples/rust.txt +27 -0
  176. package/apps/jsmdcui/tests/syntax-parity/samples/sage.txt +24 -0
  177. package/apps/jsmdcui/tests/syntax-parity/samples/scad.txt +23 -0
  178. package/apps/jsmdcui/tests/syntax-parity/samples/scala.txt +16 -0
  179. package/apps/jsmdcui/tests/syntax-parity/samples/sed.txt +12 -0
  180. package/apps/jsmdcui/tests/syntax-parity/samples/sh.txt +20 -0
  181. package/apps/jsmdcui/tests/syntax-parity/samples/sls.txt +15 -0
  182. package/apps/jsmdcui/tests/syntax-parity/samples/smalltalk.txt +28 -0
  183. package/apps/jsmdcui/tests/syntax-parity/samples/solidity.txt +26 -0
  184. package/apps/jsmdcui/tests/syntax-parity/samples/sql.txt +35 -0
  185. package/apps/jsmdcui/tests/syntax-parity/samples/stata.txt +32 -0
  186. package/apps/jsmdcui/tests/syntax-parity/samples/svelte.txt +10 -0
  187. package/apps/jsmdcui/tests/syntax-parity/samples/swift.txt +53 -0
  188. package/apps/jsmdcui/tests/syntax-parity/samples/systemd.txt +15 -0
  189. package/apps/jsmdcui/tests/syntax-parity/samples/tcl.txt +17 -0
  190. package/apps/jsmdcui/tests/syntax-parity/samples/terraform.txt +28 -0
  191. package/apps/jsmdcui/tests/syntax-parity/samples/tex.txt +15 -0
  192. package/apps/jsmdcui/tests/syntax-parity/samples/toml.txt +31 -0
  193. package/apps/jsmdcui/tests/syntax-parity/samples/twig.txt +29 -0
  194. package/apps/jsmdcui/tests/syntax-parity/samples/typescript.txt +32 -0
  195. package/apps/jsmdcui/tests/syntax-parity/samples/v.txt +47 -0
  196. package/apps/jsmdcui/tests/syntax-parity/samples/vala.txt +22 -0
  197. package/apps/jsmdcui/tests/syntax-parity/samples/verilog.txt +37 -0
  198. package/apps/jsmdcui/tests/syntax-parity/samples/vhdl.txt +37 -0
  199. package/apps/jsmdcui/tests/syntax-parity/samples/vi.txt +17 -0
  200. package/apps/jsmdcui/tests/syntax-parity/samples/vue.txt +29 -0
  201. package/apps/jsmdcui/tests/syntax-parity/samples/xml.txt +14 -0
  202. package/apps/jsmdcui/tests/syntax-parity/samples/xresources.txt +14 -0
  203. package/apps/jsmdcui/tests/syntax-parity/samples/yaml.txt +19 -0
  204. package/apps/jsmdcui/tests/syntax-parity/samples/yum.txt +12 -0
  205. package/apps/jsmdcui/tests/syntax-parity/samples/zig.txt +24 -0
  206. package/apps/jsmdcui/tests/syntax-parity/samples/zscript.txt +31 -0
  207. package/apps/jsmdcui/tests/syntax-parity/samples/zsh.txt +21 -0
  208. package/package.json +1 -1
@@ -0,0 +1,106 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/touch-drag-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('touch double-tap drag and image-menu right click use bundled xterm', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/index.css'){res.setHeader('Content-Type','text/css');res.end(fs.readFileSync(path.join(root,'css/index.css')));}
16
+ else if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
17
+ else if(req.url==='/gestures.js'){
18
+ const source=fs.readFileSync(path.join(root,'js/kitty.js'),'utf8');
19
+ res.setHeader('Content-Type','text/javascript');
20
+ res.end(source.slice(source.indexOf(' function reportTouchMouse('),source.indexOf(' function ensureContextMenu('))+source.slice(source.indexOf(' function ensureContextMenu('),source.indexOf(' function bindImageContextMenu('))+source.slice(source.indexOf(' function bindImageContextMenu('),source.indexOf(' function bindImageContextMenu(')+source.slice(source.indexOf(' function bindImageContextMenu(')).indexOf('\n function ',5))+'\nbindTouchScrolling();bindImageContextMenu();');
21
+ }
22
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
23
+ else res.end('<!doctype html><div id="terminal"><div id="fixture" style="width:800px;height:600px"></div></div><link rel="stylesheet" href="/index.css"><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
24
+ });
25
+ server.listen(0,'127.0.0.1');await once(server,'listening');
26
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
27
+ let ws;
28
+ try{
29
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
30
+ const u=new URL(endpoint);let tab;
31
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
32
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
33
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
34
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
35
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
36
+ const term=wrapper.term;
37
+ const write=s=>new Promise(r=>term.write(s,r));
38
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
39
+ await write('\\x1b[?1002h\\x1b[?1006h');
40
+ const terminal=document.getElementById('fixture');terminal.__gottyXterm=term;
41
+ const screen=term.element.querySelector('.xterm-screen'), rect=screen.getBoundingClientRect();
42
+ const state={placements:new Map()};
43
+ const xtermElements=()=>({screen});
44
+ const DEFAULT_TOUCH_SCROLL_THRESHOLD=24;
45
+ const removePlacementNode=node=>node.remove();
46
+ eval(await (await fetch('/gestures.js')).text());
47
+ const touch=(type,x=30,y=30,count=1)=>{
48
+ const point=new Touch({identifier:1,target:screen,clientX:rect.left+x,clientY:rect.top+y});
49
+ const touches=type==='touchend'||type==='touchcancel'?[]:count===1?[point]:[point,new Touch({identifier:2,target:screen,clientX:rect.left+x+30,clientY:rect.top+y})];
50
+ const event=new TouchEvent(type,{bubbles:true,cancelable:true,touches,changedTouches:[point]});
51
+ screen.dispatchEvent(event);return event.defaultPrevented;
52
+ };
53
+ const codes=()=>sent.map(s=>{const m=s.match(/<([0-9]+);[0-9]+;[0-9]+([Mm])/);return m?m[1]+m[2]:s;});
54
+ let focusCalls=0;const originalFocus=term._core.focus;term._core.focus=function(...args){focusCalls++;return originalFocus.apply(this,args);};
55
+ const firstTap=()=>{touch('touchstart');touch('touchend');
56
+ // Browser compatibility mouse sequence after an ordinary tap.
57
+ for(const type of ['mousedown','mouseup'])screen.dispatchEvent(new MouseEvent(type,{bubbles:true,clientX:rect.left+30,clientY:rect.top+30,button:0,buttons:type==='mousedown'?1:0}));};
58
+ sent.length=0;firstTap();equal(codes(),['0M','0m']);
59
+ const focusBeforeDrag=focusCalls;
60
+ if(!touch('touchstart'))throw Error('Second press must suppress compatibility click');
61
+ equal(codes(),['0M','0m','0M']); // still held, no premature release
62
+ touch('touchmove',80,70);touch('touchend',80,70);
63
+ equal(codes(),['0M','0m','0M','32M','0m']);
64
+ equal(focusCalls,focusBeforeDrag);
65
+ sent.length=0;firstTap();touch('touchstart');touch('touchend');
66
+ equal(codes(),['0M','0m','0M','0m']); // ordinary double click
67
+ for(const cancel of [()=>touch('touchcancel'),()=>touch('touchstart',30,30,2),()=>window.dispatchEvent(new Event('blur')),()=>state.cancelTouchGesture()]){
68
+ sent.length=0;firstTap();touch('touchstart');cancel();touch('touchend');equal(codes(),['0M','0m','0M','0m']);
69
+ }
70
+ sent.length=0;firstTap();touch('touchstart');
71
+ touch('touchmove',-200,-200);touch('touchend',-200,-200);
72
+ equal(codes(),['0M','0m','0M','32M','0m']); // release outside grid still reaches application
73
+ sent.length=0;touch('touchstart');touch('touchmove',30,90);touch('touchend',30,90);
74
+ if(!codes().length||codes().some(x=>!x.startsWith('64')))throw Error('Single touch must remain scrolling: '+codes());
75
+ sent.length=0;touch('touchstart');touch('touchmove',30,90);touch('touchend',30,90);
76
+ if(codes().some(x=>x==='0M'))throw Error('Scroll must not arm double tap');
77
+ // Use precisely the existing image context-menu path, with its original location.
78
+ const node=document.createElement('div');node.style.zIndex='0';node.getBoundingClientRect=()=>({left:0,top:0,right:1000,bottom:1000});state.placements.set('image',node);
79
+ screen.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,clientX:rect.left+30,clientY:rect.top+30,button:2}));
80
+ if(state.contextMenu.root.hidden)throw Error('Menu missing');
81
+ const focusBeforeRight=focusCalls;
82
+ sent.length=0;state.contextMenu.rightButton.click();equal(focusCalls,focusBeforeRight);equal(codes(),['2M','2m']);
83
+ if(!state.contextMenu.root.hidden||!state.placements.has('image'))throw Error('Right click must hide menu and preserve image');
84
+ await write('\\x1b[?1002l');state.cancelTouchGesture();sent.length=0;
85
+ firstTap();touch('touchstart');touch('touchmove',80,70);touch('touchend');
86
+ if(codes().some(x=>/^[032][Mm]$/.test(x)))throw Error('No application mouse reporting outside mouse mode');
87
+ state.cancelTouchGesture();state.placements.clear();
88
+ term.focus();const textarea=term.textarea;
89
+ screen.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,clientX:rect.left+30,clientY:rect.top+30,button:2}));
90
+ if(document.activeElement!==textarea)throw Error('Context menu must keep textarea focused for native paste');
91
+ const data=new DataTransfer();data.setData('text/plain','paste-check');sent.length=0;
92
+ textarea.dispatchEvent(new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:data}));
93
+ if(!sent.join('').includes('paste-check'))throw Error('Paste stopped working');
94
+ equal(getComputedStyle(textarea).touchAction,'none');
95
+ equal(getComputedStyle(screen).touchAction,'manipulation');
96
+ touch('touchstart');
97
+ if(document.activeElement!==textarea)throw Error('Touch must not blur the input');
98
+ touch('touchend');
99
+ wrapper.close();return 'ok';
100
+ })()`});
101
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
102
+ }finally{
103
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
104
+ server.closeAllConnections();await new Promise(r=>server.close(r));
105
+ }
106
+ });
@@ -0,0 +1,60 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('shipped xterm reports horizontal, vertical and diagonal wheel events', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
16
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
17
+ else res.end('<!doctype html><div id="fixture" style="width:800px;height:600px"></div><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
18
+ });
19
+ server.listen(0,'127.0.0.1');await once(server,'listening');
20
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
21
+ let ws;
22
+ try{
23
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
24
+ const u=new URL(endpoint);let tab;
25
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
26
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
27
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
28
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
29
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
30
+ const term=wrapper.term;
31
+ const write=s=>new Promise(r=>term.write(s,r));
32
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
33
+ await write('\\x1b[?1002h\\x1b[?1006h');
34
+ const screen=term.element.querySelector('.xterm-screen'), rect=screen.getBoundingClientRect();
35
+ const wheel=(deltaX,deltaY,mods={})=>{
36
+ sent.length=0;
37
+ const event=new WheelEvent('wheel',{clientX:rect.left+20,clientY:rect.top+20,deltaX,deltaY,deltaMode:1,bubbles:true,cancelable:true,...mods});
38
+ screen.dispatchEvent(event);
39
+ if(term._core.coreMouseService.areMouseEventsActive&&!event.defaultPrevented)throw Error('Wheel default was not prevented');
40
+ return sent.map(s=>Number(s.match(/<([0-9]+);/)[1]));
41
+ };
42
+ equal(wheel(-1,0),[66]);equal(wheel(1,0),[67]);
43
+ equal(wheel(0,-1,{shiftKey:true}),[68]);equal(wheel(0,1,{shiftKey:true}),[69]);
44
+ equal(wheel(0,-1),[64]);equal(wheel(0,1),[65]);
45
+ equal(wheel(1,-1),[67,64]);
46
+ equal(wheel(-1,0,{shiftKey:true,altKey:true,ctrlKey:true}),[94]);
47
+ for(let bits=0;bits<8;bits++) {
48
+ const mods={shiftKey:!!(bits&1),altKey:!!(bits&2),ctrlKey:!!(bits&4)};
49
+ for(const [dx,dy,code] of [[0,-1,64],[0,1,65],[-1,0,66],[1,0,67]])
50
+ equal(wheel(dx,dy,mods),[code+(bits<<2)]);
51
+ }
52
+ await write('\\x1b[?1002l');equal(wheel(1,0),[]);
53
+ wrapper.close();return 'ok';
54
+ })()`});
55
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
56
+ }finally{
57
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
58
+ server.closeAllConnections();await new Promise(r=>server.close(r));
59
+ }
60
+ });
@@ -2,6 +2,50 @@
2
2
 
3
3
  All notable user-visible changes to jsmdcui are documented here.
4
4
 
5
+ ## [0.19.4] - 2026-10-26
6
+
7
+ ### Fixed
8
+
9
+ - Catch failures during Go regexp conversion as well as compilation, and
10
+ bound the converter's nested-group recursion. An excessively nested rule
11
+ is disabled individually instead of throwing from `SyntaxDefinition` or
12
+ causing the loader to silently discard the entire syntax definition.
13
+ Other rules remain usable; this does not limit input line length.
14
+
15
+ ## [0.19.3] - 2026-10-26
16
+
17
+ ### Fixed
18
+
19
+ - Restore Dockerfile highlighting and improve compatibility with Micro's
20
+ syntax definitions by translating Go regular-expression flags and POSIX
21
+ character classes into JavaScript equivalents. Scoped flags retain their
22
+ scope rather than changing the entire expression.
23
+ - Fix nested highlighting regions, especially HTML and XML attributes:
24
+ closing a quoted attribute now returns to the surrounding tag, and closing
25
+ the tag returns to normal text. Unfinished attributes still carry their
26
+ highlighting state across lines.
27
+ - Replace recursive region scanning with an iterative scanner so long,
28
+ minified lines can be highlighted through their final token without stack
29
+ exhaustion or truncation.
30
+ - Prevent zero-width region cycles and cyclic syntax/state trees from looping
31
+ indefinitely. Zero-width matches advance safely over Unicode characters,
32
+ and NUL characters or isolated surrogate units do not leak region state.
33
+ These guards do not impose a timeout on an individual JavaScript RegExp.
34
+
35
+ ### Added
36
+
37
+ - Add syntax samples and a reproducible comparison against Micro's Go
38
+ highlighter, including JavaScript, Python and Markdown with upstream syntax
39
+ definitions. See [the comparison guide](tests/syntax-parity/README.md) for
40
+ generating the HTML comparison locally; generated HTML is not bundled.
41
+
42
+ ### Changed
43
+
44
+ - Markdown heading tests detect Bun's actual raw-HTML parsing behavior instead
45
+ of relying on version checks, accommodating the slash-tag behavior change
46
+ while preserving fail-closed coverage. The heading suites were verified on
47
+ Bun 1.4.0 through 1.4.4.
48
+
5
49
  ## [0.19.2] - 2026-09-21
6
50
 
7
51
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.19.2",
3
+ "version": "0.19.4",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,83 @@
1
+ // Adapt Go regexp syntax without changing the upstream syntax definitions.
2
+ // Scoped JS modifiers preserve flag changes across alternatives, but not out
3
+ // of their enclosing group. They add no capturing groups. Modifiers are only
4
+ // emitted where flags differ from those in effect, so flag-free patterns
5
+ // do not depend on engine support for them.
6
+ export function convertGoRegexp(pattern) {
7
+ const source = String(pattern);
8
+ if (!/\(\?[ims-]|\[\^?\]|\(\?P<|\[:[a-z]+:\]/.test(source)) return source;
9
+ let i = 0;
10
+ function expression(inherited, active, nested = false, depth = 0) {
11
+ // Bound syntax-rule recursion, not the length of text being highlighted.
12
+ if (depth > 256) throw new RangeError('Go regexp nesting exceeds 256 groups');
13
+ const flags = new Set(inherited);
14
+ let output = '', wrapped = false;
15
+ function open() {
16
+ const on = 'ims'.split('').filter(f => flags.has(f) && !active.has(f)).join('');
17
+ const off = 'ims'.split('').filter(f => !flags.has(f) && active.has(f)).join('');
18
+ if (!on && !off) return;
19
+ output += `(?${on}${off ? '-' + off : ''}:`;
20
+ wrapped = true;
21
+ }
22
+ function close() { if (wrapped) output += ')'; wrapped = false; }
23
+ while (i < source.length) {
24
+ const ch = source[i];
25
+ if (ch === ')' && nested) { i++; close(); return output; }
26
+ if (ch === '|') { close(); output += '|'; i++; continue; }
27
+ const directive = ch === '(' && source.slice(i).match(/^\(\?([ims]*)(?:-([ims]+))?\)/);
28
+ if (directive && (directive[1] || directive[2])) {
29
+ close();
30
+ for (const f of directive[1]) flags.add(f);
31
+ for (const f of directive[2] || '') flags.delete(f);
32
+ i += directive[0].length;
33
+ continue;
34
+ }
35
+ if (!wrapped) open();
36
+ if (ch === '\\') { output += source.slice(i, i + 2); i += 2; continue; }
37
+ if (ch === '[') {
38
+ output += source[i++];
39
+ if (source[i] === '^') output += source[i++];
40
+ // Go permits a literal ] as the first member of a character class.
41
+ if (source[i] === ']') { output += '\\]'; i++; }
42
+ while (i < source.length) {
43
+ const posix = source.slice(i).match(/^\[:([a-z]+):\]/);
44
+ if (posix && POSIX_CLASSES[posix[1]] !== undefined) {
45
+ output += POSIX_CLASSES[posix[1]]; i += posix[0].length; continue;
46
+ }
47
+ const c = source[i++]; output += c;
48
+ if (c === '\\' && i < source.length) output += source[i++];
49
+ else if (c === ']') break;
50
+ }
51
+ continue;
52
+ }
53
+ if (ch === '(') {
54
+ const scoped = source.slice(i).match(/^\(\?([ims]*)(?:-([ims]+))?:/);
55
+ const childFlags = new Set(flags);
56
+ if (scoped) {
57
+ for (const f of scoped[1]) childFlags.add(f);
58
+ for (const f of scoped[2] || '') childFlags.delete(f);
59
+ output += '(?:'; i += scoped[0].length;
60
+ } else {
61
+ const prefix = source.slice(i).match(/^\(\?(?:[:=!]|<[=!]|<[^>]+>|P<[^>]+>)/);
62
+ output += prefix ? prefix[0].replace('(?P<', '(?<') : '(';
63
+ i += prefix ? prefix[0].length : 1;
64
+ }
65
+ output += expression(childFlags, flags, true, depth + 1) + ')';
66
+ continue;
67
+ }
68
+ output += ch; i++;
69
+ }
70
+ close();
71
+ return output;
72
+ }
73
+ return expression(new Set(), new Set());
74
+ }
75
+
76
+ // Class contents, not nested [...] expressions: e.g. [[:alnum:]._-].
77
+ const POSIX_CLASSES = {
78
+ alnum: 'A-Za-z0-9', alpha: 'A-Za-z', ascii: '\\x00-\\x7F',
79
+ blank: ' \\t', cntrl: '\\x00-\\x1F\\x7F', digit: '0-9',
80
+ graph: '!-~', lower: 'a-z', print: ' -~',
81
+ punct: '!-/:-@\\[-`{-~', space: '\\t\\n\\v\\f\\r ',
82
+ upper: 'A-Z', word: 'A-Za-z0-9_', xdigit: 'A-Fa-f0-9',
83
+ };
@@ -8,9 +8,7 @@ export class Highlighter {
8
8
  highlightLine(line, state = null, progress = null) {
9
9
  const text = String(line);
10
10
  const changes = new Map([[0, state?.group ?? "default"]]);
11
- const nextState = state
12
- ? scanRegion(changes, text, 0, state, this.rules, progress)
13
- : scanTop(changes, text, 0, this.rules, progress);
11
+ const nextState = scanLine(changes, text, state, this.rules, progress);
14
12
  return {
15
13
  changes: normalizeChanges(changes, text.length),
16
14
  state: nextState,
@@ -29,16 +27,18 @@ export class Highlighter {
29
27
  }
30
28
  }
31
29
 
32
- export function resolveRules(rules, definitions, seen) {
30
+ export function resolveRules(rules, definitions, seen, ancestors = new Set()) {
31
+ if (ancestors.has(rules) || ancestors.size >= 128) return [];
32
+ const path = new Set(ancestors); path.add(rules);
33
33
  const out = [];
34
34
  for (const rule of rules) {
35
35
  if (rule.type === "include") {
36
36
  if (seen.has(rule.include)) continue;
37
37
  seen.add(rule.include);
38
38
  const def = definitions.find((candidate) => candidate.filetype === rule.include);
39
- if (def) out.push(...resolveRules(def.rules, definitions, seen));
39
+ if (def) out.push(...resolveRules(def.rules, definitions, seen, path));
40
40
  } else if (rule.type === "region") {
41
- out.push({ ...rule, rules: resolveRules(rule.rules ?? [], definitions, new Set(seen)) });
41
+ out.push({ ...rule, rules: resolveRules(rule.rules ?? [], definitions, new Set(seen), path) });
42
42
  } else {
43
43
  out.push(rule);
44
44
  }
@@ -53,67 +53,67 @@ export function flattenRules(rules, definitions, seen) {
53
53
  });
54
54
  }
55
55
 
56
- function scanTop(changes, line, pos, rules, progress = null) {
57
- let cursor = pos;
56
+ // Iterative state machine: adjacent tokens on a minified line must not consume
57
+ // call-stack depth. A state repeated at an unchanged position steps forward.
58
+ function scanLine(changes, line, region, rootRules, progress) {
59
+ let cursor = 0, previous = -1;
60
+ const visited = new Set();
61
+ const parents = new Set();
62
+ for (let r = region; r; r = r.parent) {
63
+ if (parents.has(r) || parents.size >= 128) { region = null; changes.set(0, "default"); break; }
64
+ parents.add(r);
65
+ }
58
66
  while (cursor <= line.length) {
59
- progress?.(cursor);
60
- const regionStart = findFirstRegion(rules, line, cursor, progress);
61
- if (!regionStart) {
62
- applyPatterns(changes, line, cursor, line.length, rules, "default", progress);
63
- changes.set(line.length, "default");
64
- progress?.(line.length);
67
+ if (cursor !== previous) { visited.clear(); previous = cursor; }
68
+ if (visited.has(region)) {
69
+ // A zero-width child can end and reopen forever at the same position.
70
+ // Skip one character in the current region, like a zero-width match.
71
+ cursor = advanceIndex(line, cursor);
72
+ continue;
73
+ }
74
+ if (visited.size >= 256) {
75
+ // Fresh object states from malformed externally supplied trees.
76
+ // Discard this line's unfinished region; do not poison subsequent lines.
77
+ changes.set(cursor, "default"); changes.set(line.length, "default");
65
78
  return null;
66
79
  }
67
-
68
- applyPatterns(changes, line, cursor, regionStart.start, rules, "default", progress);
69
- changes.set(regionStart.start, regionStart.region.limitGroup ?? "default");
70
- changes.set(regionStart.end, regionStart.region.group);
71
- progress?.(regionStart.end);
72
-
73
- const state = scanRegion(changes, line, regionStart.end, regionStart.region, rules, progress);
74
- if (state) return state;
75
- return null;
76
- }
77
- return null;
78
- }
79
-
80
- function scanRegion(changes, line, pos, region, rootRules, progress = null) {
81
- let cursor = pos;
82
- const rules = region.rules ?? [];
83
- while (cursor <= line.length) {
80
+ visited.add(region);
84
81
  progress?.(cursor);
85
- const end = findMatch(region.end, line, cursor, region.skip, progress);
82
+ const rules = region?.rules ?? rootRules;
83
+ const end = region ? findMatch(region.end, line, cursor, region.skip, progress) : null;
86
84
  const nested = findFirstRegion(rules, line, cursor, progress);
87
-
88
85
  if (end && (!nested || end.start <= nested.start)) {
89
86
  applyPatterns(changes, line, cursor, end.start, rules, region.group, progress);
90
87
  changes.set(end.start, region.limitGroup ?? region.group);
91
88
  changes.set(end.end, parentGroup(region));
92
89
  progress?.(end.end);
93
- if (region.parent) return scanRegion(changes, line, end.end, region.parent, rootRules, progress);
94
- return scanTop(changes, line, Math.max(end.end, end.start + 1), rootRules, progress);
90
+ const parent = region.parent ?? null;
91
+ cursor = parent ? end.end : Math.max(end.end, advanceIndex(line, end.start));
92
+ region = parent;
93
+ continue;
95
94
  }
96
-
97
95
  if (nested) {
98
- applyPatterns(changes, line, cursor, nested.start, rules, region.group, progress);
99
- changes.set(nested.start, nested.region.limitGroup ?? region.group);
100
- const child = { ...nested.region, parent: region };
101
- changes.set(nested.end, child.group);
96
+ applyPatterns(changes, line, cursor, nested.start, rules, region?.group ?? "default", progress);
97
+ changes.set(nested.start, nested.region.limitGroup ?? nested.region.group);
98
+ region = region ? { ...nested.region, parent: region } : nested.region;
99
+ changes.set(nested.end, region.group);
102
100
  progress?.(nested.end);
103
- const state = scanRegion(changes, line, nested.end, child, rootRules, progress);
104
- if (state) return state;
105
- cursor = Math.max(nested.end + 1, nextChangeAfter(changes, nested.end));
101
+ cursor = nested.end;
106
102
  continue;
107
103
  }
108
-
109
- applyPatterns(changes, line, cursor, line.length, rules, region.group, progress);
110
- changes.set(line.length, region.group);
104
+ applyPatterns(changes, line, cursor, line.length, rules, region?.group ?? "default", progress);
105
+ changes.set(line.length, region?.group ?? "default");
111
106
  progress?.(line.length);
112
107
  return region;
113
108
  }
114
109
  return region;
115
110
  }
116
111
 
112
+ // AdvanceStringIndex for Unicode regexps, including isolated surrogate units.
113
+ function advanceIndex(text, index) {
114
+ return index + (text.codePointAt(index) > 0xFFFF ? 2 : 1);
115
+ }
116
+
117
117
  function applyPatterns(changes, line, start, end, rules, fallbackGroup, progress = null) {
118
118
  if (end < start) return;
119
119
  const length = end - start;
@@ -161,18 +161,12 @@ function findMatch(regex, line, pos, skip, progress = null) {
161
161
  const global = globalize(regex);
162
162
  global.lastIndex = pos;
163
163
  let match;
164
- if (!progress) {
165
- match = global.exec(masked);
166
- if (!match) return null;
167
- const start = match.index ?? 0;
168
- return { start, end: start + match[0].length };
169
- }
170
164
  while ((match = global.exec(masked)) !== null) {
171
165
  const start = match.index ?? 0;
172
166
  const end = start + match[0].length;
173
167
  progress?.(start);
174
168
  if (start >= pos) return { start, end };
175
- if (match[0].length === 0) global.lastIndex++;
169
+ global.lastIndex = Math.max(global.lastIndex, advanceIndex(masked, start));
176
170
  }
177
171
  progress?.(line.length);
178
172
  return null;
@@ -189,7 +183,7 @@ function findAllMatches(regex, line, start, end, progress = null) {
189
183
  if (matchStart >= end) break;
190
184
  const matchEnd = matchStart + match[0].length;
191
185
  if (match[0].length === 0) {
192
- global.lastIndex++;
186
+ global.lastIndex = advanceIndex(line, matchStart);
193
187
  continue;
194
188
  }
195
189
  if (matchEnd > start) out.push({ start: Math.max(matchStart, start), end: Math.min(matchEnd, end) });
@@ -202,7 +196,7 @@ function findAllMatches(regex, line, start, end, progress = null) {
202
196
  if (matchStart >= end) break;
203
197
  progress?.(matchStart);
204
198
  if (match[0].length === 0) {
205
- global.lastIndex++;
199
+ global.lastIndex = advanceIndex(line, matchStart);
206
200
  continue;
207
201
  }
208
202
  if (matchEnd <= start || matchStart >= end) continue;
@@ -229,13 +223,6 @@ function parentGroup(region) {
229
223
  return region.parent?.group ?? "default";
230
224
  }
231
225
 
232
- function nextChangeAfter(changes, index) {
233
- for (const key of [...changes.keys()].sort((a, b) => a - b)) {
234
- if (key > index) return key;
235
- }
236
- return index + 1;
237
- }
238
-
239
226
  function globalize(regex) {
240
227
  const flags = new Set(regex.flags.split(""));
241
228
  flags.add("g");
@@ -1,4 +1,5 @@
1
1
  import { basename } from "node:path";
2
+ import { convertGoRegexp } from "./go-regexp.js";
2
3
 
3
4
  export class SyntaxHeader {
4
5
  constructor({ filetype = "", filename = "", header = "", signature = "" } = {}) {
@@ -195,15 +196,17 @@ function parseRule(rule) {
195
196
 
196
197
  function compileRegex(pattern) {
197
198
  if (!pattern) return null;
198
- const translated = translateGoRegexp(pattern);
199
199
  try {
200
- return new RegExp(translated, "u");
201
- } catch {
200
+ // Conversion may reject an excessively nested rule. Keep that failure
201
+ // local to this regexp rather than dropping the entire syntax definition.
202
+ const translated = convertGoRegexp(translateGoRegexp(pattern));
202
203
  try {
203
- return new RegExp(translated);
204
+ return new RegExp(translated, "u");
204
205
  } catch {
205
- return null;
206
+ return new RegExp(translated);
206
207
  }
208
+ } catch {
209
+ return null;
207
210
  }
208
211
  }
209
212
 
@@ -0,0 +1,98 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {convertGoRegexp} from '../src/highlight/go-regexp.js';
3
+ import {SyntaxDefinition,parseHeaderYaml} from '../src/highlight/parser.js';
4
+ const regex = s => new RegExp(convertGoRegexp(s), 'u');
5
+ test('Go flags apply to the remaining alternatives, preserving precedence', () => {
6
+ const r = regex('^a(?i)b$|^c$');
7
+ expect(r.test('aB')).toBe(true);
8
+ expect(r.test('AB')).toBe(false);
9
+ expect(r.test('C')).toBe(true);
10
+ expect(r.test('aC')).toBe(false);
11
+ });
12
+ test('flag changes stay inside groups and can be disabled', () => {
13
+ const r = regex('^(a(?i)b|c)d$');
14
+ expect(r.test('aBd')).toBe(true);
15
+ expect(r.test('Cd')).toBe(true);
16
+ expect(r.test('CD')).toBe(false);
17
+ expect(regex('^(?i)a(?-i)b$').test('Ab')).toBe(true);
18
+ expect(regex('^(?i)a(?-i)b$').test('AB')).toBe(false);
19
+ expect(regex('^(?i:a(?-i:b))c$').test('Abc')).toBe(true);
20
+ expect(regex('^(?i:a(?-i:b))c$').test('ABC')).toBe(false);
21
+ });
22
+ test('capture numbers, scoped flags, escaped literals and class brackets survive', () => {
23
+ expect(regex('^(?i)(a)(b)$').exec('AB').slice(1)).toEqual(['A','B']);
24
+ expect(regex('(?i:a)+').test('AAA')).toBe(true);
25
+ expect(regex('\\(\\?i\\)').test('(?i)')).toBe(true);
26
+ const url = regex('(https?|ftp|ssh)://\\S*[^])>\\s,.]');
27
+ expect(url.exec('https://example.com)')[0]).toBe('https://example.com');
28
+ expect(regex('(?s)^a.b$').test('a\nb')).toBe(true);
29
+ expect(regex('(?m)^b$').test('a\nb\nc')).toBe(true);
30
+ expect(convertGoRegexp('\\bword\\b')).toBe('\\bword\\b');
31
+ });
32
+ test('all bundled syntax regexes compile, and Dockerfile keywords match', async () => {
33
+ const root = new URL('../runtime/syntax/', import.meta.url).pathname;
34
+ const failures = [];
35
+ function walk(rules, file) {
36
+ for (const r of rules) {
37
+ if (r.type === 'pattern' && !r.regex) failures.push(file+': '+r.source);
38
+ if (r.type === 'region') {
39
+ for (const k of ['start','end','skip']) if (r.source[k] && !r[k]) failures.push(file+': '+r.source[k]);
40
+ walk(r.rules,file);
41
+ }
42
+ }
43
+ }
44
+ for await (const file of new Bun.Glob('*.yaml').scan(root)) {
45
+ const source = Bun.YAML.parse(await Bun.file(root+'/'+file).text());
46
+ const header = parseHeaderYaml(source);
47
+ for (const [key,re] of [['filename','fileNameRegex'],['header','headerRegex'],['signature','signatureRegex']])
48
+ if (header[key] && !header[re]) failures.push(file+': '+key);
49
+ const def = new SyntaxDefinition(header,source);
50
+ walk(def.rules,file);
51
+ if (file === 'dockerfile.yaml') {
52
+ expect(header.matchFileName('/tmp/Dockerfile')).toBe(true);
53
+ const keyword = def.rules.find(r=>r.group==='type.keyword').regex;
54
+ for (const line of ['FROM debian','run echo hi','COPY . /app']) expect(keyword.test(line)).toBe(true);
55
+ expect(keyword.test('FROMAGE debian')).toBe(false);
56
+ }
57
+ }
58
+ expect(failures).toEqual([]);
59
+ });
60
+ test('mixed and negated POSIX classes retain ASCII Go semantics',()=>{
61
+ expect(regex('^[[:alnum:].%_+-]+@[[:alnum:].-]+$').test('a.b+tag@example.com')).toBe(true);
62
+ expect(regex('^[^[:space:]]+:$').test('Subject:')).toBe(true);
63
+ expect(regex('^[^[:space:]]+:$').test('Bad Header:')).toBe(false);
64
+ expect(regex('^[{[:space:]]-block([[:space:]{}]|$)').test(' -block ')).toBe(true);
65
+ expect(regex('^[x[:space:]]$').test('\t')).toBe(true);
66
+ expect(regex('^[x[:space:]]$').test('\u00a0')).toBe(false);
67
+ expect(regex('^[x[:punct:]]$').test('[')).toBe(true);
68
+ expect(regex('^[x[:punct:]]$').test('A')).toBe(false);
69
+ });
70
+ test('modifiers are emitted only where flags change',()=>{
71
+ expect(convertGoRegexp('[]a]')).toBe('[\\]a]');
72
+ expect(convertGoRegexp('(?P<x>a)')).toBe('(?<x>a)');
73
+ expect(convertGoRegexp('[[:alpha:]_]')).toBe('[A-Za-z_]');
74
+ expect(convertGoRegexp('(?i)\\b(select|from)\\b')).toBe('(?i:\\b(select|from)\\b)');
75
+ expect(regex('^(?i)a(?-i:b)$').test('Ab')).toBe(true);
76
+ expect(regex('^(?i)a(?-i:b)$').test('AB')).toBe(false);
77
+ });
78
+
79
+ test('converter bounds nested groups without limiting adjacent groups',()=>{
80
+ expect(()=>convertGoRegexp('(?i)'+'('.repeat(50000)+'x'+')'.repeat(50000))).toThrow('nesting exceeds');
81
+ expect(regex('(?i)'+'('.repeat(256)+'x'+')'.repeat(256)).test('X')).toBe(true);
82
+ expect(()=>convertGoRegexp('(?i)'+'(x)'.repeat(50000))).not.toThrow();
83
+ });
84
+
85
+ test('conversion failure disables only its rule and preserves the loaded syntax',async()=>{
86
+ const {loadSyntaxDefinitions}=await import('../src/highlight/parser.js');
87
+ const {Highlighter}=await import('../src/highlight/highlighter.js');
88
+ const deep='(?i)'+'('.repeat(50000)+'x'+')'.repeat(50000);
89
+ const source={filetype:'guarded',detect:{filename:'\\.guarded$'},rules:[{bad:deep},{keyword:'(?i)hello'},{invalid:'['}]};
90
+ const def=new SyntaxDefinition(parseHeaderYaml(source),source);
91
+ expect(def.rules[0].regex).toBeNull();expect(def.rules[2].regex).toBeNull();
92
+ expect(def.rules[1].regex.test('HELLO')).toBe(true);
93
+ const runtime={list:type=>type===1?[{name:'guarded',text:async()=>JSON.stringify(source)}]:[]};
94
+ const defs=await loadSyntaxDefinitions(runtime);
95
+ expect(defs.map(d=>d.filetype)).toEqual(['guarded']);
96
+ expect(defs[0].header.matchFileName('example.guarded')).toBe(true);
97
+ expect(new Highlighter(defs[0],defs).highlightLine('HELLO').changes.get(0)).toBe('keyword');
98
+ });