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