scan_op.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. // @ts-check
  2. const { containsNonBmpChars } = require('../util')
  3. const {
  4. ApplyError,
  5. InvalidInsertionError,
  6. UnprocessableError,
  7. } = require('../errors')
  8. /** @typedef {{ result: string, inputCursor: number}} ApplyContext */
  9. /** @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext */
  10. class ScanOp {
  11. constructor() {
  12. if (this.constructor === ScanOp) {
  13. throw new Error('Cannot instantiate abstract class')
  14. }
  15. }
  16. /**
  17. * Applies an operation to a string
  18. * @param {string} input
  19. * @param {ApplyContext} current
  20. * @returns {ApplyContext}
  21. */
  22. apply(input, current) {
  23. throw new Error('abstract method')
  24. }
  25. /**
  26. * Applies an operation to a length
  27. * @param {LengthApplyContext} current
  28. * @returns {LengthApplyContext}
  29. */
  30. applyToLength(current) {
  31. throw new Error('abstract method')
  32. }
  33. toJSON() {
  34. throw new Error('abstract method')
  35. }
  36. /**
  37. * @param {object} raw
  38. * @returns {ScanOp}
  39. */
  40. static fromJSON(raw) {
  41. if (isRetain(raw)) {
  42. return RetainOp.fromJSON(raw)
  43. } else if (isInsert(raw)) {
  44. return InsertOp.fromJSON(raw)
  45. } else if (isRemove(raw)) {
  46. return RemoveOp.fromJSON(raw)
  47. }
  48. throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`)
  49. }
  50. /**
  51. * Tests whether two ScanOps are equal
  52. * @param {ScanOp} _other
  53. * @returns {boolean}
  54. */
  55. equals(_other) {
  56. return false
  57. }
  58. /**
  59. * Tests whether two ScanOps can be merged into a single operation
  60. * @param {ScanOp} other
  61. * @returns
  62. */
  63. canMergeWith(other) {
  64. return false
  65. }
  66. /**
  67. * Merge two ScanOps into a single operation
  68. * @param {ScanOp} _other
  69. * @returns {void}
  70. */
  71. mergeWith(_other) {
  72. throw new Error('abstract method')
  73. }
  74. toString() {
  75. 'ScanOp'
  76. }
  77. }
  78. class InsertOp extends ScanOp {
  79. constructor(insertion) {
  80. super()
  81. if (typeof insertion !== 'string') {
  82. throw new InvalidInsertionError('insertion must be a string')
  83. }
  84. if (containsNonBmpChars(insertion)) {
  85. throw new InvalidInsertionError('insertion contains non-BMP characters')
  86. }
  87. this.insertion = insertion
  88. }
  89. /**
  90. *
  91. * @param {{i: string} | string} op
  92. * @returns {InsertOp}
  93. */
  94. static fromJSON(op) {
  95. if (typeof op === 'string') {
  96. return new InsertOp(op)
  97. }
  98. // It must be an object with an 'i' property.
  99. if (typeof op.i !== 'string') {
  100. throw new InvalidInsertionError(
  101. 'insert operation must have a string property'
  102. )
  103. }
  104. return new InsertOp(op.i)
  105. }
  106. /**
  107. * @inheritdoc
  108. * @param {string} input
  109. * @param {ApplyContext} current
  110. * @returns {ApplyContext}
  111. * */
  112. apply(input, current) {
  113. if (containsNonBmpChars(this.insertion)) {
  114. throw new InvalidInsertionError(input, this.toJSON())
  115. }
  116. current.result += this.insertion
  117. return current
  118. }
  119. /**
  120. * @inheritdoc
  121. * @param {LengthApplyContext} current
  122. * @returns {LengthApplyContext}
  123. */
  124. applyToLength(current) {
  125. current.length += this.insertion.length
  126. return current
  127. }
  128. /** @inheritdoc */
  129. equals(other) {
  130. if (!(other instanceof InsertOp)) {
  131. return false
  132. }
  133. return this.insertion === other.insertion
  134. }
  135. canMergeWith(other) {
  136. return other instanceof InsertOp
  137. }
  138. mergeWith(other) {
  139. if (!(other instanceof InsertOp)) {
  140. throw new Error('Cannot merge with incompatible operation')
  141. }
  142. this.insertion += other.insertion
  143. }
  144. toJSON() {
  145. // TODO: Once we add metadata to the operation, generate an object rather
  146. // than the compact representation.
  147. return this.insertion
  148. }
  149. toString() {
  150. return `insert '${this.insertion}'`
  151. }
  152. }
  153. class RetainOp extends ScanOp {
  154. constructor(length) {
  155. super()
  156. if (length < 0) {
  157. throw new Error('length must be non-negative')
  158. }
  159. this.length = length
  160. }
  161. /**
  162. * @inheritdoc
  163. * @param {string} input
  164. * @param {ApplyContext} current
  165. * @returns {ApplyContext}
  166. * */
  167. apply(input, current) {
  168. if (current.inputCursor + this.length > input.length) {
  169. throw new ApplyError(
  170. "Operation can't retain more chars than are left in the string.",
  171. this.toJSON(),
  172. input
  173. )
  174. }
  175. current.result += input.slice(
  176. current.inputCursor,
  177. current.inputCursor + this.length
  178. )
  179. current.inputCursor += this.length
  180. return current
  181. }
  182. /**
  183. * @inheritdoc
  184. * @param {LengthApplyContext} current
  185. * @returns {LengthApplyContext}
  186. */
  187. applyToLength(current) {
  188. if (current.inputCursor + this.length > current.inputLength) {
  189. throw new ApplyError(
  190. "Operation can't retain more chars than are left in the string.",
  191. this.toJSON(),
  192. current.inputLength
  193. )
  194. }
  195. current.length += this.length
  196. current.inputCursor += this.length
  197. return current
  198. }
  199. /**
  200. *
  201. * @param {number | {r: number}} op
  202. * @returns
  203. */
  204. static fromJSON(op) {
  205. if (typeof op === 'number') {
  206. return new RetainOp(op)
  207. }
  208. // It must be an object with a 'r' property.
  209. if (typeof op.r !== 'number') {
  210. throw new Error('retain operation must have a number property')
  211. }
  212. return new RetainOp(op.r)
  213. }
  214. /** @inheritdoc */
  215. equals(other) {
  216. if (!(other instanceof RetainOp)) {
  217. return false
  218. }
  219. return this.length === other.length
  220. }
  221. canMergeWith(other) {
  222. return other instanceof RetainOp
  223. }
  224. mergeWith(other) {
  225. if (!(other instanceof RetainOp)) {
  226. throw new Error('Cannot merge with incompatible operation')
  227. }
  228. this.length += other.length
  229. }
  230. toJSON() {
  231. // TODO: Once we add metadata to the operation, generate an object rather
  232. // than the compact representation.
  233. return this.length
  234. }
  235. toString() {
  236. return `retain ${this.length}`
  237. }
  238. }
  239. class RemoveOp extends ScanOp {
  240. constructor(length) {
  241. super()
  242. if (length < 0) {
  243. throw new Error('length must be non-negative')
  244. }
  245. this.length = length
  246. }
  247. /**
  248. * @inheritdoc
  249. * @param {string} _input
  250. * @param {ApplyContext} current
  251. * @returns {ApplyContext}
  252. */
  253. apply(_input, current) {
  254. current.inputCursor += this.length
  255. return current
  256. }
  257. /**
  258. * @inheritdoc
  259. * @param {LengthApplyContext} current
  260. * @returns {LengthApplyContext}
  261. */
  262. applyToLength(current) {
  263. current.inputCursor += this.length
  264. return current
  265. }
  266. /**
  267. *
  268. * @param {number} op
  269. * @returns {RemoveOp}
  270. */
  271. static fromJSON(op) {
  272. if (typeof op !== 'number' || op > 0) {
  273. throw new Error('delete operation must be a negative number')
  274. }
  275. return new RemoveOp(-op)
  276. }
  277. /** @inheritdoc */
  278. equals(other) {
  279. if (!(other instanceof RemoveOp)) {
  280. return false
  281. }
  282. return this.length === other.length
  283. }
  284. canMergeWith(other) {
  285. return other instanceof RemoveOp
  286. }
  287. mergeWith(other) {
  288. if (!(other instanceof RemoveOp)) {
  289. throw new Error('Cannot merge with incompatible operation')
  290. }
  291. this.length += other.length
  292. }
  293. toJSON() {
  294. return -this.length
  295. }
  296. toString() {
  297. return `remove ${this.length}`
  298. }
  299. }
  300. function isRetain(op) {
  301. return (
  302. (typeof op === 'number' && op > 0) ||
  303. (typeof op === 'object' && typeof op.r === 'number' && op.r > 0)
  304. )
  305. }
  306. function isInsert(op) {
  307. return (
  308. typeof op === 'string' ||
  309. (typeof op === 'object' && typeof op.i === 'string')
  310. )
  311. }
  312. function isRemove(op) {
  313. return typeof op === 'number' && op < 0
  314. }
  315. module.exports = {
  316. ScanOp,
  317. InsertOp,
  318. RetainOp,
  319. RemoveOp,
  320. isRetain,
  321. isInsert,
  322. isRemove,
  323. }