1// Copyright (c) Jupyter Development Team. 2// Distributed under the terms of the Modified BSD License. 3'use strict'; 4 5import * as nbformat from '@jupyterlab/nbformat'; 6 7import { 8 JSONObject, JSONArray, JSONExt, JSONValue, PartialJSONObject 9} from '@lumino/coreutils'; 10 11import { 12 IDiffEntry 13} from '../diffentries'; 14 15import { 16 DiffRangeRaw, DiffRangePos, raw2Pos 17} from '../range'; 18 19import { 20 Chunk, LineChunker 21} from '../../chunking'; 22 23import { 24 patchStringified, stringifyAndBlankNull 25} from '../../patch'; 26 27import { 28 IDiffModel 29} from './common'; 30 31 32/** 33 * Interface for a string diff model. 34 * 35 * String diff models are used for any content where the final 36 * diff should be presented as a difference between strings 37 * (as compared to e.g. images). As such, it is NOT restricted 38 * to cases where original content is in a string format. 39 */ 40export 41interface IStringDiffModel extends IDiffModel { 42 /** 43 * Base value 44 */ 45 base: string | null; 46 47 /** 48 * Remote value 49 */ 50 remote: string | null; 51 52 /** 53 * Mimetype of the data the string represents. 54 * 55 * Can be used for things such as syntax highlighting. 56 */ 57 mimetype: string; 58 59 /** 60 * Location of additions, as positions in the remote value. 61 * 62 * Locations should be sorted on the ranges' `from` position 63 */ 64 additions: DiffRangePos[]; 65 66 /** 67 * Location of deletions, as positions in the base value. 68 * 69 * Locations should be sorted on the ranges' `from` position 70 */ 71 deletions: DiffRangePos[]; 72 73 /** 74 * A function that will separate the diff into chunks. 75 */ 76 getLineChunks(): Chunk[]; 77 78 /** 79 * Create an iterator for iterating over the diffs in order 80 */ 81 iterateDiffs(): StringDiffModel.DiffIter; 82} 83 84 85/** 86 * Standard implementation of the IStringDiffModel interface. 87 */ 88export 89class StringDiffModel implements IStringDiffModel { 90 91 /** 92 * StringDiffModel constructor. 93 * 94 * Will translate additions and deletions from absolute 95 * coordinates, into {line, ch} based coordinates. 96 * Both should be sorted on the `from` position before passing. 97 * 98 * Collapsible and collapsed both defaults to false. 99 */ 100 constructor( 101 public base: string | null, 102 public remote: string | null, 103 additions: DiffRangeRaw[], 104 deletions: DiffRangeRaw[], 105 collapsible?: boolean, 106 header?: string, 107 collapsed?: boolean) { 108 if (base === null) { 109 console.assert(deletions.length === 0); 110 this.deletions = []; 111 } else { 112 this.deletions = raw2Pos(deletions, base); 113 } 114 if (remote === null) { 115 console.assert(additions.length === 0); 116 this.additions = []; 117 } else { 118 this.additions = raw2Pos(additions, remote); 119 } 120 121 this.collapsible = collapsible === true; 122 if (this.collapsible) { 123 this.collapsibleHeader = header ? header : ''; 124 this.startCollapsed = collapsed === true; 125 } 126 } 127 128 iterateDiffs(): StringDiffModel.DiffIter { 129 return new StringDiffModel.DiffIter(this); 130 } 131 132 /** 133 * Chunk additions/deletions into line-based chunks 134 */ 135 getLineChunks(): Chunk[] { 136 let chunker = new LineChunker(); 137 let i = this.iterateDiffs(); 138 for (let v = i.next(); v !== undefined; v = i.next()) { 139 chunker.addDiff(v.range, v.isAddition); 140 } 141 return chunker.chunks; 142 } 143 144 get unchanged(): boolean { 145 return this.base === this.remote; 146 } 147 148 get added(): boolean { 149 return this.base === null; 150 } 151 152 get deleted(): boolean { 153 return this.remote === null; 154 } 155 156 collapsible: boolean; 157 collapsibleHeader: string; 158 startCollapsed: boolean; 159 160 mimetype: string; 161 162 additions: DiffRangePos[]; 163 deletions: DiffRangePos[]; 164} 165 166 167export 168namespace StringDiffModel { 169 export 170 type DiffIterValue = {range: DiffRangePos, isAddition: boolean} | undefined; 171 172 export 173 interface IIterator<T> { 174 next(): T | undefined; 175 } 176 177 export 178 class DiffIter implements IIterator<DiffIterValue> { 179 constructor(model: IStringDiffModel) { 180 this.model = model; 181 } 182 183 next(): DiffIterValue | undefined { 184 // Figure out which element to take next 185 let isAddition: boolean | null = null; 186 let range: DiffRangePos | null = null; 187 let additions = this.model.additions; 188 let deletions = this.model.deletions; 189 let hintTakeDeletion = this.hintTakeDeletion; 190 this.hintTakeDeletion = false; 191 if (this.ia < this.model.additions.length) { 192 if (this.id < deletions.length) { 193 let ra = additions[this.ia]; 194 let rd = deletions[this.id]; 195 if (ra.from.line === rd.from.line - this.editOffset && 196 ra.from.ch === rd.from.ch) { 197 // An addition and deletion start at seemingly same location 198 // Take addition, and flag to ensure deletion gets taken next 199 if (hintTakeDeletion) { 200 isAddition = false; 201 } else { 202 this.hintTakeDeletion = true; 203 isAddition = true; 204 } 205 } else if (ra.from.line < rd.from.line - this.editOffset || 206 (ra.from.line === rd.from.line - this.editOffset && 207 ra.from.ch < rd.from.ch)) { 208 // TODO: Character editOffset should also be used 209 isAddition = true; 210 } else { 211 isAddition = false; 212 } 213 } else { 214 // No more deletions 215 isAddition = true; 216 } 217 } else if (this.id < deletions.length) { 218 // No more additions 219 isAddition = false; 220 } else { 221 // Out of ranges! 222 this.done = true; 223 return undefined; 224 } 225 226 if (isAddition) { 227 range = additions[this.ia++]; 228 } else { 229 range = deletions[this.id++]; 230 } 231 let linediff = range.to.line - range.from.line; 232 if (range.endsOnNewline) { 233 linediff += 1; 234 } 235 this.editOffset += isAddition ? -linediff : linediff; 236 return {range: range, isAddition: isAddition}; 237 } 238 239 editOffset = 0; 240 done = false; 241 242 protected model: IStringDiffModel; 243 protected ia = 0; 244 protected id = 0; 245 protected hintTakeDeletion = false; 246 } 247 248 export 249 class SyncedDiffIter implements IIterator<DiffIterValue> { 250 static cmp(a: DiffIterValue, b: DiffIterValue, 251 offsetA: number, offsetB: number) { 252 if (a === undefined && b === undefined) { 253 return 0; 254 } else if (a === undefined) { 255 return 1; 256 } else if (b === undefined) { 257 return -1; 258 } 259 let lineA = a.range.from.line + (a.isAddition ? offsetA : 0); 260 let lineB = b.range.from.line + (b.isAddition ? offsetB : 0); 261 if (lineA < lineB || a.range.from.ch < b.range.from.ch) { 262 return -1; 263 } else if (lineA > lineB || a.range.from.ch > b.range.from.ch) { 264 return 1; 265 } else { 266 return 0; 267 } 268 } 269 270 constructor(models: (IStringDiffModel | null)[]) { 271 this.models = []; 272 this.iterators = []; 273 this.values = []; 274 this.offsets = []; 275 // Set up iterator and dummy chunkers for other models 276 for (let m of models) { 277 if (m === null) { 278 continue; 279 } 280 this.models.push(m); 281 let it = m.iterateDiffs(); 282 this.iterators.push(it); 283 this.offsets.push(0); 284 this.values.push(it.next()); 285 } 286 } 287 288 next(): DiffIterValue { 289 // Compare in base index to see which diff is next 290 let i = 0; 291 for (let j = 1; j < this.values.length; ++j) { 292 if (0 > SyncedDiffIter.cmp(this.values[j], this.values[i], 293 this.iterators[j].editOffset, 294 this.iterators[i].editOffset)) { 295 i = j; 296 } 297 } 298 this.i = i; 299 let ret = this.values[i]; 300 // Store the edit offset before taking next value 301 this.currentOffset = this.offsets[i]; 302 this.offsets[i] = this.iterators[i].editOffset; 303 // Check if complete 304 if (ret !== undefined) { 305 this.values[i] = this.iterators[i].next(); 306 } 307 return ret; 308 } 309 310 currentModel(): IStringDiffModel { 311 return this.models[this.i]; 312 } 313 314 currentOffset = 0; 315 316 protected i: number; 317 318 protected models: IStringDiffModel[]; 319 protected iterators: DiffIter[]; 320 protected values: DiffIterValue[]; 321 protected offsets: number[]; 322 } 323} 324 325 326/** 327 * Creates a StringDiffModel based on a patch operation. 328 * 329 * If base is not a string, it is assumed to be a JSON object/array, 330 * and it will be stringified according to JSON stringification 331 * rules. 332 */ 333export 334function createPatchStringDiffModel(base: string | JSONObject | JSONArray | PartialJSONObject, diff: IDiffEntry[]) : StringDiffModel { 335 console.assert(!!diff, 'Patch model needs diff.'); 336 const baseCopy = JSONExt.deepCopy(base) as JSONObject 337 let baseStr = stringifyAndBlankNull(baseCopy); 338 let out = patchStringified(baseCopy, diff); 339 return new StringDiffModel(baseStr, out.remote, out.additions, out.deletions); 340} 341 342 343/** 344 * Factory for creating cell diff models for added, removed or unchanged content. 345 * 346 * If base is null, it will be treated as added, if remote is null it will be 347 * treated as removed. Otherwise base and remote should be equal, represeting 348 * unchanged content. 349 */ 350export 351function createDirectStringDiffModel(base: JSONValue | null, remote: JSONValue | null): StringDiffModel { 352 let baseStr: string | null = stringifyAndBlankNull(base); 353 let remoteStr: string | null = stringifyAndBlankNull(remote); 354 let additions: DiffRangeRaw[] = []; 355 let deletions: DiffRangeRaw[] = []; 356 357 if (base === null && remote === null) { 358 throw new Error('Invalid arguments to createDirectStringDiffModel(). ' + 359 'Both base and remote cannot be equal!'); 360 } else if (base === null) { 361 // Added cell 362 baseStr = null; 363 additions.push(new DiffRangeRaw(0, remoteStr.length, undefined)); 364 } else if (remote === null) { 365 // Deleted cell 366 remoteStr = null; 367 deletions.push(new DiffRangeRaw(0, baseStr.length, undefined)); 368 } else if (remoteStr !== baseStr) { 369 throw new Error('Invalid arguments to createDirectStringDiffModel(). ' + 370 'Either base or remote should be null, or they should be equal!'); 371 } 372 return new StringDiffModel(baseStr, remoteStr, additions, deletions); 373} 374 375 376/** 377 * Assign MIME type to an IStringDiffModel based on the cell type. 378 * 379 * The parameter nbMimetype is the MIME type set for the entire notebook, and is 380 * used as the MIME type for code cells. 381 */ 382export 383function setMimetypeFromCellType(model: IStringDiffModel, cell: nbformat.ICell, 384 nbMimetype: string) { 385 if (cell.cell_type === 'code') { 386 model.mimetype = nbMimetype; 387 } else if (cell.cell_type === 'markdown') { 388 model.mimetype = 'text/markdown'; 389 } else if (nbformat.isRaw(cell)) { 390 model.mimetype = cell.metadata.format || 'text/plain'; 391 } 392} 393