From f626e1c93d09da42bb74cc5d0e29d9bb645ebd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Thu, 25 Jul 2019 20:38:14 +0200 Subject: [PATCH] fix OSC parsing --- src/InputHandler.ts | 14 +- src/common/parser/Constants.ts | 10 + .../parser/EscapeSequenceParser.test.ts | 96 +++++--- src/common/parser/EscapeSequenceParser.ts | 90 ++----- src/common/parser/OscParser.test.ts | 222 ++++++++++++++++++ src/common/parser/OscParser.ts | 186 +++++++++++++++ src/common/parser/Types.d.ts | 46 +++- .../EscapeSequenceParser.benchmark.ts | 5 +- typings/xterm.d.ts | 2 +- 9 files changed, 561 insertions(+), 110 deletions(-) create mode 100644 src/common/parser/OscParser.test.ts create mode 100644 src/common/parser/OscParser.ts diff --git a/src/InputHandler.ts b/src/InputHandler.ts index f47085d4..0e322370 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -21,6 +21,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { IAttributeData, IDisposable } from 'common/Types'; import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService } from 'common/services/Services'; import { ISelectionService } from 'browser/services/Services'; +import { OscHandlerFactory } from 'common/parser/OscParser'; /** * Map collect to glevel. Used in `selectCharset`. @@ -152,9 +153,10 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.setExecuteHandlerFallback((code: number) => { this._logService.debug('Unknown EXECUTE code: ', { code }); }); - this._parser.setOscHandlerFallback((identifier: number, data: string) => { - this._logService.debug('Unknown OSC code: ', { identifier, data }); - }); + this._parser.setOscHandlerFallback((identifier, action, data) => { + this._logService.debug('Unknown OSC code: ', { identifier, action, data }); + } + ); /** * print handler @@ -224,10 +226,10 @@ export class InputHandler extends Disposable implements IInputHandler { * OSC handler */ // 0 - icon name + title - this._parser.setOscHandler(0, (data) => this.setTitle(data)); + this._parser.setOscHandler(0, new OscHandlerFactory((data: string) => this.setTitle(data))); // 1 - icon name // 2 - title - this._parser.setOscHandler(2, (data) => this.setTitle(data)); + this._parser.setOscHandler(2, new OscHandlerFactory((data: string) => this.setTitle(data))); // 3 - set property X in the form "prop=value" // 4 - Change Color Number // 5 - Change Special Color Number @@ -485,7 +487,7 @@ export class InputHandler extends Disposable implements IInputHandler { * Forward addOscHandler from parser. */ public addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { - return this._parser.addOscHandler(ident, callback); + return this._parser.addOscHandler(ident, new OscHandlerFactory(callback)); } /** diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 55e4a005..9f28a27a 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -43,3 +43,13 @@ export const enum ParserAction { DCS_PUT = 13, DCS_UNHOOK = 14 } + +/** + * Internal states of OscParser. + */ +export const enum OscState { + START = 0, + ID = 1, + PAYLOAD = 2, + ABORT = 3 +} diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 90ec0ee0..a2b4b499 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -3,12 +3,14 @@ * @license MIT */ -import { IDcsHandler, IParsingState, IParams, ParamsArray } from 'common/parser/Types'; +import { IDcsHandler, IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandler } from 'common/parser/Types'; import { EscapeSequenceParser, TransitionTable, VT500_TRANSITION_TABLE } from 'common/parser/EscapeSequenceParser'; import * as chai from 'chai'; -import { StringToUtf32, stringFromCodePoint } from 'common/input/TextDecoder'; +import { StringToUtf32, stringFromCodePoint, utf32ToString } from 'common/input/TextDecoder'; import { ParserState } from 'common/parser/Constants'; import { Params } from 'common/parser/Params'; +import { OscHandlerFactory } from 'common/parser/OscParser'; +import { IDisposable } from 'common/Types'; function r(a: number, b: number): string[] { @@ -20,13 +22,45 @@ function r(a: number, b: number): string[] { return arr; } +class MockOscPutParser implements IOscParser { + private _fallback: OscFallbackHandler = () => {}; + public data = ''; + public reset(): void { + this.data = ''; + } + public put(data: Uint32Array, start: number, end: number): void { + this.data += utf32ToString(data, start, end); + } + public dispose(): void { } + public start(): void { } + public end(): void { + const id = parseInt(this.data.slice(0, this.data.indexOf(';'))); + if (!isNaN(id)) { + this._fallback(id, 'END', this.data.slice(this.data.indexOf(';') + 1)); + } + } + addOscHandler(ident: number, handler: IOscHandler): IDisposable { + throw new Error('not implemented'); + } + setOscHandler(ident: number, handler: IOscHandler): void { + throw new Error('not implemented'); + } + clearOscHandler(ident: number): void { + throw new Error('not implemented'); + } + setOscHandlerFallback(handler: OscFallbackHandler): void { + this._fallback = handler; + } +} +const oscPutParser = new MockOscPutParser(); + // derived parser with access to internal states class TestEscapeSequenceParser extends EscapeSequenceParser { public get osc(): string { - return this._osc; + return (this._oscParser as MockOscPutParser).data; } public set osc(value: string) { - this._osc = value; + (this._oscParser as MockOscPutParser).data = value; } public get params(): ParamsArray { return this._params.toArray(); @@ -46,6 +80,9 @@ class TestEscapeSequenceParser extends EscapeSequenceParser { public mockActiveDcsHandler(): void { this._activeDcsHandler = this._dcsHandlerFb; } + public mockOscParser(): void { + this._oscParser = oscPutParser; + } } // test object to collect parser actions and compare them with expected values @@ -124,6 +161,7 @@ let state: any; // parser with Uint8Array based transition table const testParser = new TestEscapeSequenceParser(); +testParser.mockOscParser(); testParser.setPrintHandler(testTerminal.print.bind(testTerminal)); testParser.setCsiHandlerFallback((collect: string, params: IParams, flag: number) => { testTerminal.actionCSI(collect, params, String.fromCharCode(flag)); @@ -134,9 +172,9 @@ testParser.setEscHandlerFallback((collect: string, flag: number) => { testParser.setExecuteHandlerFallback((code: number) => { testTerminal.actionExecute(String.fromCharCode(code)); }); -testParser.setOscHandlerFallback((identifier: number, data: string) => { +testParser.setOscHandlerFallback((identifier, action, data) => { if (identifier === -1) testTerminal.actionOSC(data); // handle error condition silently - else testTerminal.actionOSC('' + identifier + ';' + data); + else if (action === 'END') testTerminal.actionOSC('' + identifier + ';' + data); // collect only data at END }); testParser.setDcsHandlerFallback(new DcsTest()); @@ -1026,9 +1064,9 @@ describe('EscapeSequenceParser', function (): void { ], null); }); it('print + OSC(C1) + print', function (): void { - test('abc\x9d123tzf\x9cdefg', [ + test('abc\x9d123;tzf\x9cdefg', [ ['print', 'abc'], - ['osc', '123tzf'], + ['osc', '123;tzf'], ['print', 'defg'] ], null); }); @@ -1039,9 +1077,9 @@ describe('EscapeSequenceParser', function (): void { ], null); }); it('7bit ST should be swallowed', function (): void { - test('abc\x9d123tzf\x1b\\defg', [ + test('abc\x9d123;tzf\x1b\\defg', [ ['print', 'abc'], - ['osc', '123tzf'], + ['osc', '123;tzf'], ['print', 'defg'] ], null); }); @@ -1256,9 +1294,9 @@ describe('EscapeSequenceParser', function (): void { chai.expect(exe).eql(['\n']); }); it('OSC handler', function (): void { - parser2.setOscHandler(1, function (data: string): void { + parser2.setOscHandler(1, new OscHandlerFactory(function (data: string): void { osc.push([1, data]); - }); + })); parse(parser2, INPUT); chai.expect(osc).eql([[1, 'foo=bar']]); parser2.clearOscHandler(1); @@ -1270,16 +1308,16 @@ describe('EscapeSequenceParser', function (): void { describe('OSC custom handlers', () => { it('Prevent fallback', () => { const oscCustom: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return true; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return true; })); parse(parser2, INPUT); chai.expect(osc).eql([], 'Should not fallback to original handler'); chai.expect(oscCustom).eql([[1, 'foo=bar']]); }); it('Allow fallback', () => { const oscCustom: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return false; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return false; })); parse(parser2, INPUT); chai.expect(osc).eql([[1, 'foo=bar']], 'Should fallback to original handler'); chai.expect(oscCustom).eql([[1, 'foo=bar']]); @@ -1287,9 +1325,9 @@ describe('EscapeSequenceParser', function (): void { it('Multiple custom handlers fallback once', () => { const oscCustom: [number, string][] = []; const oscCustom2: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return true; }); - parser2.addOscHandler(1, data => { oscCustom2.push([1, data]); return false; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return true; })); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom2.push([1, data]); return false; })); parse(parser2, INPUT); chai.expect(osc).eql([], 'Should not fallback to original handler'); chai.expect(oscCustom).eql([[1, 'foo=bar']]); @@ -1298,9 +1336,9 @@ describe('EscapeSequenceParser', function (): void { it('Multiple custom handlers no fallback', () => { const oscCustom: [number, string][] = []; const oscCustom2: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return true; }); - parser2.addOscHandler(1, data => { oscCustom2.push([1, data]); return true; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return true; })); + parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom2.push([1, data]); return true; })); parse(parser2, INPUT); chai.expect(osc).eql([], 'Should not fallback to original handler'); chai.expect(oscCustom).eql([], 'Should not fallback once'); @@ -1308,16 +1346,16 @@ describe('EscapeSequenceParser', function (): void { }); it('Execution order should go from latest handler down to the original', () => { const order: number[] = []; - parser2.setOscHandler(1, () => order.push(1)); - parser2.addOscHandler(1, () => { order.push(2); return false; }); - parser2.addOscHandler(1, () => { order.push(3); return false; }); + parser2.setOscHandler(1, new OscHandlerFactory(() => order.push(1))); + parser2.addOscHandler(1, new OscHandlerFactory(() => { order.push(2); return false; })); + parser2.addOscHandler(1, new OscHandlerFactory(() => { order.push(3); return false; })); parse(parser2, '\x1b]1;foo=bar\x1b\\'); chai.expect(order).eql([3, 2, 1]); }); it('Dispose should work', () => { const oscCustom: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - const customHandler = parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return true; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + const customHandler = parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return true; })); customHandler.dispose(); parse(parser2, INPUT); chai.expect(osc).eql([[1, 'foo=bar']]); @@ -1325,8 +1363,8 @@ describe('EscapeSequenceParser', function (): void { }); it('Should not corrupt the parser when dispose is called twice', () => { const oscCustom: [number, string][] = []; - parser2.setOscHandler(1, data => osc.push([1, data])); - const customHandler = parser2.addOscHandler(1, data => { oscCustom.push([1, data]); return true; }); + parser2.setOscHandler(1, new OscHandlerFactory(data => osc.push([1, data]))); + const customHandler = parser2.addOscHandler(1, new OscHandlerFactory(data => { oscCustom.push([1, data]); return true; })); customHandler.dispose(); customHandler.dispose(); parse(parser2, INPUT); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 99af8a10..e2e88728 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -3,20 +3,13 @@ * @license MIT */ -import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams } from 'common/parser/Types'; +import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandler, OscFallbackHandler, IOscParser } from 'common/parser/Types'; import { ParserState, ParserAction } from 'common/parser/Constants'; import { Disposable } from 'common/Lifecycle'; -import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; import { fill } from 'common/TypedArrayUtils'; import { Params } from 'common/parser/Params'; - -interface IHandlerCollection { - [key: string]: T[]; -} - -type CsiHandler = (params: IParams, collect: string) => boolean | void; -type OscHandler = (data: string) => boolean | void; +import { OscParser } from 'common/parser/OscParser'; /** * Table values are generated like this: @@ -240,7 +233,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP public precedingCodepoint: number; // buffers over several parse calls - protected _osc: string; protected _params: Params; protected _collect: string; @@ -249,7 +241,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP protected _executeHandlers: any; protected _csiHandlers: IHandlerCollection; protected _escHandlers: any; - protected _oscHandlers: IHandlerCollection; + protected _oscParser: IOscParser; protected _dcsHandlers: any; protected _activeDcsHandler: IDcsHandler; protected _errorHandler: (state: IParsingState) => IParsingState; @@ -259,7 +251,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP protected _executeHandlerFb: (code: number) => void; protected _csiHandlerFb: (collect: string, params: IParams, flag: number) => void; protected _escHandlerFb: (collect: string, flag: number) => void; - protected _oscHandlerFb: (identifier: number, data: string) => void; protected _dcsHandlerFb: IDcsHandler; protected _errorHandlerFb: (state: IParsingState) => IParsingState; @@ -268,7 +259,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.initialState = ParserState.GROUND; this.currentState = this.initialState; - this._osc = ''; this._params = new Params(); // defaults to 32 storable params/subparams this._params.addParam(0); // ZDM this._collect = ''; @@ -279,14 +269,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._executeHandlerFb = (code: number): void => { }; this._csiHandlerFb = (collect: string, params: IParams, flag: number): void => { }; this._escHandlerFb = (collect: string, flag: number): void => { }; - this._oscHandlerFb = (identifier: number, data: string): void => { }; this._dcsHandlerFb = new DcsDummy(); this._errorHandlerFb = (state: IParsingState): IParsingState => state; this._printHandler = this._printHandlerFb; this._executeHandlers = Object.create(null); this._csiHandlers = Object.create(null); this._escHandlers = Object.create(null); - this._oscHandlers = Object.create(null); + this._oscParser = new OscParser(); this._dcsHandlers = Object.create(null); this._activeDcsHandler = this._dcsHandlerFb; this._errorHandler = this._errorHandlerFb; @@ -300,6 +289,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._escHandlers = null; this._dcsHandlers = null; this._activeDcsHandler = new DcsDummy(); + this._oscParser.dispose(); } setPrintHandler(callback: (data: Uint32Array, start: number, end: number) => void): void { @@ -355,29 +345,17 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._escHandlerFb = callback; } - addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { - if (this._oscHandlers[ident] === undefined) { - this._oscHandlers[ident] = []; - } - const handlerList = this._oscHandlers[ident]; - handlerList.push(callback); - return { - dispose: () => { - const handlerIndex = handlerList.indexOf(callback); - if (handlerIndex !== -1) { - handlerList.splice(handlerIndex, 1); - } - } - }; + addOscHandler(ident: number, handler: IOscHandler): IDisposable { + return this._oscParser.addOscHandler(ident, handler); } - setOscHandler(ident: number, callback: (data: string) => void): void { - this._oscHandlers[ident] = [callback]; + setOscHandler(ident: number, handler: IOscHandler): void { + this._oscParser.setOscHandler(ident, handler); } clearOscHandler(ident: number): void { - if (this._oscHandlers[ident]) delete this._oscHandlers[ident]; + this._oscParser.clearOscHandler(ident); } - setOscHandlerFallback(callback: (identifier: number, data: string) => void): void { - this._oscHandlerFb = callback; + setOscHandlerFallback(handler: OscFallbackHandler): void { + this._oscParser.setOscHandlerFallback(handler); } setDcsHandler(collectAndFlag: string, handler: IDcsHandler): void { @@ -404,7 +382,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP reset(): void { this.currentState = this.initialState; - this._osc = ''; + this._oscParser.reset(); this._params.reset(); this._params.addParam(0); // ZDM this._collect = ''; @@ -430,7 +408,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP let code = 0; let transition = 0; let currentState = this.currentState; - let osc = this._osc; + const osc = this._oscParser; let collect = this._collect; const params = this._params; const table: Uint8Array = this.TRANSITIONS.table; @@ -484,7 +462,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP position: i, code, currentState, - osc, + osc: '', // FIXME: what to send here? collect, params, abort: false @@ -537,7 +515,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.precedingCodepoint = 0; break; case ParserAction.CLEAR: - osc = ''; + osc.reset(); params.reset(); params.addParam(0); // ZDM collect = ''; @@ -562,54 +540,29 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP dcsHandler.unhook(); dcsHandler = this._dcsHandlerFb; if (code === 0x1b) transition |= ParserState.ESCAPE; - osc = ''; + osc.reset(); params.reset(); params.addParam(0); // ZDM collect = ''; this.precedingCodepoint = 0; break; case ParserAction.OSC_START: - osc = ''; + osc.start(); break; case ParserAction.OSC_PUT: // inner loop: 0x20 (SP) included, 0x7F (DEL) included for (let j = i + 1; ; j++) { if (j >= length || (code = data[j]) < 0x20 || (code > 0x7f && code <= 0x9f)) { - osc += utf32ToString(data, i, j); + osc.put(data, i, j); i = j - 1; break; } } break; case ParserAction.OSC_END: - if (osc && code !== 0x18 && code !== 0x1a) { - // NOTE: OSC subparsing is not part of the original parser - // we do basic identifier parsing here to offer a jump table for OSC as well - const idx = osc.indexOf(';'); - if (idx === -1) { - this._oscHandlerFb(-1, osc); // this is an error (malformed OSC) - } else { - // Note: NaN is not handled here - // either catch it with the fallback handler - // or with an explicit NaN OSC handler - const identifier = parseInt(osc.substring(0, idx)); - const content = osc.substring(idx + 1); - // Trigger OSC Handler - const handlers = this._oscHandlers[identifier]; - let j = handlers ? handlers.length - 1 : -1; - for (; j >= 0; j--) { - // undefined or true means success and to stop bubbling - if (handlers[j](content) !== false) { - break; - } - } - if (j < 0) { - this._oscHandlerFb(identifier, content); - } - } - } + osc.end(code !== 0x18 && code !== 0x1a); if (code === 0x1b) transition |= ParserState.ESCAPE; - osc = ''; + osc.reset(); params.reset(); params.addParam(0); // ZDM collect = ''; @@ -620,7 +573,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP } // save non pushable buffers - this._osc = osc; this._collect = collect; this._params = params; diff --git a/src/common/parser/OscParser.test.ts b/src/common/parser/OscParser.test.ts new file mode 100644 index 00000000..9b5de5fd --- /dev/null +++ b/src/common/parser/OscParser.test.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { OscParser, OscHandlerFactory } from 'common/parser/OscParser'; +import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder'; +import { IOscHandler } from 'common/parser/Types'; + +function toUtf32(s: string): Uint32Array { + const utf32 = new Uint32Array(s.length); + const decoder = new StringToUtf32(); + const length = decoder.decode(s, utf32); + return utf32.subarray(0, length); +} + +class TestHandler implements IOscHandler { + constructor(public id: number, public output: any[], public msg: string, public returnFalse: boolean = false) {} + start(): void { + this.output.push([this.msg, this.id, 'START']); + } + put(data: Uint32Array, start: number, end: number): void { + this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]); + } + end(success: boolean): void | boolean { + this.output.push([this.msg, this.id, 'END', success]); + if (this.returnFalse) { + return false; + } + } +} + +describe('OscParser', () => { + let parser: OscParser; + let reports: any[] = []; + beforeEach(() => { + reports = []; + parser = new OscParser(); + parser.setOscHandlerFallback((id, action, data) => { + reports.push([id, action, data]); + }); + }); + describe('identifier parsing', () => { + it('no report for illegal ids', () => { + const data = toUtf32('hello world!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, []); + }); + it('no payload', () => { + parser.start(); + let data = toUtf32('12'); + parser.put(data, 0, data.length); + data = toUtf32('34'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [[1234, 'START', undefined], [1234, 'END', true]]); + }); + it('with payload', () => { + parser.start(); + let data = toUtf32('12'); + parser.put(data, 0, data.length); + data = toUtf32('34'); + parser.put(data, 0, data.length); + data = toUtf32(';h'); + parser.put(data, 0, data.length); + data = toUtf32('ello'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + [1234, 'START', undefined], + [1234, 'PUT', 'h'], + [1234, 'PUT', 'ello'], + [1234, 'END', true] + ]); + }); + }); + describe('handler registration', () => { + it('setOscHandler', () => { + parser.setOscHandler(1234, new TestHandler(1234, reports, 'th')); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + // messages from TestHandler + ['th', 1234, 'START'], + ['th', 1234, 'PUT', 'Here comes'], + ['th', 1234, 'PUT', 'the mouse!'], + ['th', 1234, 'END', true] + ]); + }); + it('clearOscHandler', () => { + parser.setOscHandler(1234, new TestHandler(1234, reports, 'th')); + parser.clearOscHandler(1234); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + // messages from fallback handler + [1234, 'START', undefined], + [1234, 'PUT', 'Here comes'], + [1234, 'PUT', 'the mouse!'], + [1234, 'END', true] + ]); + }); + it('addOscHandler', () => { + parser.setOscHandler(1234, new TestHandler(1234, reports, 'th1')); + parser.addOscHandler(1234, new TestHandler(1234, reports, 'th2')); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + ['th2', 1234, 'START'], + ['th1', 1234, 'START'], + ['th2', 1234, 'PUT', 'Here comes'], + ['th1', 1234, 'PUT', 'Here comes'], + ['th2', 1234, 'PUT', 'the mouse!'], + ['th1', 1234, 'PUT', 'the mouse!'], + ['th2', 1234, 'END', true], + ['th1', 1234, 'END', false] // false due being already handled by th2! + ]); + }); + it('addOscHandler with return false', () => { + parser.setOscHandler(1234, new TestHandler(1234, reports, 'th1')); + parser.addOscHandler(1234, new TestHandler(1234, reports, 'th2', true)); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + ['th2', 1234, 'START'], + ['th1', 1234, 'START'], + ['th2', 1234, 'PUT', 'Here comes'], + ['th1', 1234, 'PUT', 'Here comes'], + ['th2', 1234, 'PUT', 'the mouse!'], + ['th1', 1234, 'PUT', 'the mouse!'], + ['th2', 1234, 'END', true], + ['th1', 1234, 'END', true] // true since th2 indicated to keep bubbling + ]); + }); + it('dispose handlers', () => { + parser.setOscHandler(1234, new TestHandler(1234, reports, 'th1')); + const dispo = parser.addOscHandler(1234, new TestHandler(1234, reports, 'th2', true)); + dispo.dispose(); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + ['th1', 1234, 'START'], + ['th1', 1234, 'PUT', 'Here comes'], + ['th1', 1234, 'PUT', 'the mouse!'], + ['th1', 1234, 'END', true] + ]); + }); + }); + describe('OscHandlerFactory', () => { + it('should be called once on end(true)', () => { + parser.setOscHandler(1234, new OscHandlerFactory(data => reports.push([1234, data]))); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [[1234, 'Here comes the mouse!']]); + }); + it('should not be called on end(false)', () => { + parser.setOscHandler(1234, new OscHandlerFactory(data => reports.push([1234, data]))); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(false); + assert.deepEqual(reports, []); + }); + it('should be disposable', () => { + parser.setOscHandler(1234, new OscHandlerFactory(data => reports.push(['one', data]))); + const dispo = parser.addOscHandler(1234, new OscHandlerFactory(data => reports.push(['two', data]))); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!']]); + dispo.dispose(); + parser.start(); + data = toUtf32('1234;some other'); + parser.put(data, 0, data.length); + data = toUtf32(' data'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]); + }); + it('should respect return false', () => { + parser.setOscHandler(1234, new OscHandlerFactory(data => reports.push(['one', data]))); + parser.addOscHandler(1234, new OscHandlerFactory(data => { reports.push(['two', data]); return false; })); + parser.start(); + let data = toUtf32('1234;Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]); + }); + }); +}); diff --git a/src/common/parser/OscParser.ts b/src/common/parser/OscParser.ts new file mode 100644 index 00000000..1163fd20 --- /dev/null +++ b/src/common/parser/OscParser.ts @@ -0,0 +1,186 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IOscHandler, IHandlerCollection, OscFallbackHandler } from 'common/parser/Types'; +import { OscState } from 'common/parser/Constants'; +import { Disposable } from 'common/Lifecycle'; +import { utf32ToString } from 'common/input/TextDecoder'; +import { IDisposable } from 'common/Types'; + + +export class OscParser extends Disposable { + private _state = OscState.START; + private _id = -1; + private _handlers: IHandlerCollection = Object.create(null); + private _handlerFb: OscFallbackHandler = () => { }; + + addOscHandler(ident: number, handler: IOscHandler): IDisposable { + if (this._handlers[ident] === undefined) { + this._handlers[ident] = []; + } + const handlerList = this._handlers[ident]; + handlerList.push(handler); + return { + dispose: () => { + const handlerIndex = handlerList.indexOf(handler); + if (handlerIndex !== -1) { + handlerList.splice(handlerIndex, 1); + } + } + }; + } + setOscHandler(ident: number, handler: IOscHandler): void { + this._handlers[ident] = [handler]; + } + clearOscHandler(ident: number): void { + if (this._handlers[ident]) delete this._handlers[ident]; + } + setOscHandlerFallback(handler: OscFallbackHandler): void { + this._handlerFb = handler; + } + + public dispose(): void { + this._handlers = {}; + } + + public reset(): void { + // cleanup handlers if payload was already sent + if (this._state === OscState.PAYLOAD) { + this.end(false); + } + this._id = -1; + this._state = OscState.START; + } + + private _start(): void { + const handlers = this._handlers[this._id]; + if (!handlers) { + this._handlerFb(this._id, 'START'); + } else { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j].start(); + } + } + } + + private _put(data: Uint32Array, start: number, end: number): void { + const handlers = this._handlers[this._id]; + if (!handlers) { + this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end)); + } else { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j].put(data, start, end); + } + } + } + + private _end(success: boolean): void { + // other than the old code we always have to call .end + // to keep the bubbling we use `success` to indicate + // whether a handler should execute + const handlers = this._handlers[this._id]; + if (!handlers) { + this._handlerFb(this._id, 'END', success); + } else { + let j = handlers.length - 1; + for (; j >= 0; j--) { + if (handlers[j].end(success) !== false) { + break; + } + } + j--; + // cleanup left over handlers + for (; j >= 0; j--) { + handlers[j].end(false); + } + } + } + + public start(): void { + this._id = -1; + this._state = OscState.ID; + } + + /** + * Put data to current OSC command. + * Expects the identifier of the OSC command in the form + * OSC id ; payload ST/BEL + * Payload chunks are not further processed and get + * directly passed to the handlers. + */ + public put(data: Uint32Array, start: number, end: number): void { + if (this._state === OscState.ABORT) { + return; + } + if (this._state === OscState.ID) { + while (start < end) { + const code = data[start++]; + if (code === 0x3b) { + this._state = OscState.PAYLOAD; + this._start(); + break; + } + if (code < 0x30 || 0x39 < code) { + this._state = OscState.ABORT; + return; + } + if (this._id === -1) { + this._id = 0; + } + this._id = this._id * 10 + code - 48; + } + } + if (this._state === OscState.PAYLOAD && end - start > 0) { + this._put(data, start, end); + } + } + + /** + * Indicates end of an OSC command. + * Whether the OSC got aborted or finished normally + * is indicated by `success`. + */ + public end(success: boolean): void { + if (this._state === OscState.START) { + return; + } + // do nothing if command was faulty + if (this._state !== OscState.ABORT) { + // if we are still in ID state and get an early end + // means that the command has no payload thus we still have + // to announce START and send END right after + if (this._state === OscState.ID) { + this._start(); + } + this._end(success); + } + this._id = -1; + this._state = OscState.START; + } +} + + +/** + * Convenient class to allow attaching string based handler functions + * as OSC handlers. + */ +export class OscHandlerFactory implements IOscHandler { + private _data = ''; + constructor(private _handler: (data: string) => any) {} + public start(): void { + this._data = ''; + } + public put(data: Uint32Array, start: number, end: number): void { + this._data += utf32ToString(data, start, end); + } + public end(success: boolean): any { + let ret; + if (success) { + ret = this._handler(this._data); + } + this._data = ''; + return ret; + } +} diff --git a/src/common/parser/Types.d.ts b/src/common/parser/Types.d.ts index d432800d..2b21f893 100644 --- a/src/common/parser/Types.d.ts +++ b/src/common/parser/Types.d.ts @@ -63,6 +63,12 @@ export interface IParsingState { abort: boolean; } +export interface IHandlerCollection { + [key: string]: T[]; +} + +export type CsiHandler = (params: IParams, collect: string) => boolean | void; + /** * DCS handler signature for EscapeSequenceParser. * EscapeSequenceParser handles DCS commands via separate @@ -90,6 +96,29 @@ export interface IDcsHandler { unhook(): void; } +export type OscFallbackHandler = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void; + +export interface IOscHandler { + /** + * Announces start of this OSC command. + * Prepare needed data structures here. + */ + start(): void; + + /** + * Incoming data chunk. + */ + put(data: Uint32Array, start: number, end: number): void; + + /** + * End of OSC command. `success` indicates whether the + * command finished normally or got aborted, thus execution + * of the command should depend on `success`. + * To save memory cleanup data structures in `.end`. + */ + end(success: boolean): void | boolean; +} + /** * EscapeSequenceParser interface. */ @@ -123,15 +152,15 @@ export interface IEscapeSequenceParser extends IDisposable { clearCsiHandler(flag: string): void; setCsiHandlerFallback(callback: (collect: string, params: IParams, flag: number) => void): void; addCsiHandler(flag: string, callback: (params: IParams, collect: string) => boolean): IDisposable; - addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; + addOscHandler(ident: number, handler: IOscHandler): IDisposable; setEscHandler(collectAndFlag: string, callback: () => void): void; clearEscHandler(collectAndFlag: string): void; setEscHandlerFallback(callback: (collect: string, flag: number) => void): void; - setOscHandler(ident: number, callback: (data: string) => void): void; + setOscHandler(ident: number, handler: IOscHandler): void; clearOscHandler(ident: number): void; - setOscHandlerFallback(callback: (identifier: number, data: string) => void): void; + setOscHandlerFallback(handler: OscFallbackHandler): void; setDcsHandler(collectAndFlag: string, handler: IDcsHandler): void; clearDcsHandler(collectAndFlag: string): void; @@ -140,3 +169,14 @@ export interface IEscapeSequenceParser extends IDisposable { setErrorHandler(callback: (state: IParsingState) => IParsingState): void; clearErrorHandler(): void; } + +export interface IOscParser extends IDisposable { + addOscHandler(ident: number, handler: IOscHandler): IDisposable; + setOscHandler(ident: number, handler: IOscHandler): void; + clearOscHandler(ident: number): void; + setOscHandlerFallback(handler: OscFallbackHandler): void; + reset(): void; + start(): void; + put(data: Uint32Array, start: number, end: number): void; + end(success: boolean): void; +} diff --git a/test/benchmark/EscapeSequenceParser.benchmark.ts b/test/benchmark/EscapeSequenceParser.benchmark.ts index b9a6b0df..19b135c7 100644 --- a/test/benchmark/EscapeSequenceParser.benchmark.ts +++ b/test/benchmark/EscapeSequenceParser.benchmark.ts @@ -7,6 +7,7 @@ import { perfContext, before, beforeEach, ThroughputRuntimeCase } from 'xterm-be import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; import { C0, C1 } from 'common/data/EscapeSequences'; import { IDcsHandler, IParams } from 'common/parser/Types'; +import { OscHandlerFactory } from 'common/parser/OscParser'; function toUtf32(s: string): Uint32Array { @@ -79,8 +80,8 @@ perfContext('Parser throughput - 50MB data', () => { parser.setExecuteHandler(C1.IND, () => {}); parser.setExecuteHandler(C1.NEL, () => {}); parser.setExecuteHandler(C1.HTS, () => {}); - parser.setOscHandler(0, (data) => {}); - parser.setOscHandler(2, (data) => {}); + parser.setOscHandler(0, new OscHandlerFactory((data) => {})); + parser.setOscHandler(2, new OscHandlerFactory((data) => {})); parser.setEscHandler('7', () => {}); parser.setEscHandler('8', () => {}); parser.setEscHandler('D', () => {}); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d9e28b26..e0041dea 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -516,7 +516,7 @@ declare module 'xterm' { addCsiHandler(flag: string, callback: (params: (number | number[])[], collect: string) => boolean): IDisposable; /** - * (EXPERIMENTAL) Adds a handler for OSC escape sequences. + * Adds a handler for OSC escape sequences. * @param ident The number (first parameter) of the sequence. * @param callback The function to handle the escape sequence. The callback * is called with OSC data string. Return true if the sequence was handled;