|
| 1 | +// Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | +// Licensed under the MIT License. |
| 3 | +'use strict'; |
| 4 | +import * as hashjs from 'hash.js'; |
| 5 | +import { inject, injectable } from 'inversify'; |
| 6 | +import { |
| 7 | + Event, |
| 8 | + EventEmitter, |
| 9 | + Position, |
| 10 | + Range, |
| 11 | + TextDocumentChangeEvent, |
| 12 | + TextDocumentContentChangeEvent |
| 13 | +} from 'vscode'; |
| 14 | + |
| 15 | +import { IDocumentManager } from '../../common/application/types'; |
| 16 | +import { IConfigurationService } from '../../common/types'; |
| 17 | +import { generateCells } from '../cellFactory'; |
| 18 | +import { concatMultilineString } from '../common'; |
| 19 | +import { Identifiers } from '../constants'; |
| 20 | +import { InteractiveWindowMessages, IRemoteAddCode, SysInfoReason } from '../interactive-window/interactiveWindowTypes'; |
| 21 | +import { ICellHash, ICellHashProvider, IFileHashes, IInteractiveWindowListener } from '../types'; |
| 22 | + |
| 23 | +interface IRangedCellHash extends ICellHash { |
| 24 | + code: string; |
| 25 | + startOffset: number; |
| 26 | + endOffset: number; |
| 27 | + deleted: boolean; |
| 28 | + realCode: string; |
| 29 | +} |
| 30 | + |
| 31 | +// This class provides hashes for debugging jupyter cells. Call getHashes just before starting debugging to compute all of the |
| 32 | +// hashes for cells. |
| 33 | +@injectable() |
| 34 | +export class CellHashProvider implements ICellHashProvider, IInteractiveWindowListener { |
| 35 | + |
| 36 | + // tslint:disable-next-line: no-any |
| 37 | + private postEmitter: EventEmitter<{message: string; payload: any}> = new EventEmitter<{message: string; payload: any}>(); |
| 38 | + // Map of file to Map of start line to actual hash |
| 39 | + private hashes : Map<string, IRangedCellHash[]> = new Map<string, IRangedCellHash[]>(); |
| 40 | + private executionCount: number = 0; |
| 41 | + |
| 42 | + constructor( |
| 43 | + @inject(IDocumentManager) private documentManager: IDocumentManager, |
| 44 | + @inject(IConfigurationService) private configService: IConfigurationService |
| 45 | + ) |
| 46 | + { |
| 47 | + // Watch document changes so we can update our hashes |
| 48 | + this.documentManager.onDidChangeTextDocument(this.onChangedDocument.bind(this)); |
| 49 | + } |
| 50 | + |
| 51 | + public dispose() { |
| 52 | + this.hashes.clear(); |
| 53 | + } |
| 54 | + |
| 55 | + // tslint:disable-next-line: no-any |
| 56 | + public get postMessage(): Event<{ message: string; payload: any }> { |
| 57 | + return this.postEmitter.event; |
| 58 | + } |
| 59 | + |
| 60 | + // tslint:disable-next-line: no-any |
| 61 | + public onMessage(message: string, payload?: any): void { |
| 62 | + switch (message) { |
| 63 | + case InteractiveWindowMessages.RemoteAddCode: |
| 64 | + if (payload) { |
| 65 | + this.onAboutToAddCode(payload); |
| 66 | + } |
| 67 | + break; |
| 68 | + |
| 69 | + case InteractiveWindowMessages.AddedSysInfo: |
| 70 | + if (payload && payload.type) { |
| 71 | + const reason = payload.type as SysInfoReason; |
| 72 | + if (reason !== SysInfoReason.Interrupt) { |
| 73 | + this.hashes.clear(); |
| 74 | + } |
| 75 | + } |
| 76 | + break; |
| 77 | + |
| 78 | + default: |
| 79 | + break; |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + public getHashes(): IFileHashes[] { |
| 84 | + return [...this.hashes.entries()].map(e => { |
| 85 | + return { |
| 86 | + file: e[0], |
| 87 | + hashes: e[1].filter(h => !h.deleted) |
| 88 | + }; |
| 89 | + }).filter(e => e.hashes.length > 0); |
| 90 | + } |
| 91 | + |
| 92 | + private onAboutToAddCode(args: IRemoteAddCode) { |
| 93 | + // Make sure this is valid |
| 94 | + if (args && args.code && args.line !== undefined && args.file) { |
| 95 | + // First make sure not a markdown cell. Those can be ignored. Just get out the first code cell. |
| 96 | + // Regardless of how many 'code' cells exist in the code sent to us, we'll only ever send one at most. |
| 97 | + // The code sent to this function is either a cell as defined by #%% or the selected text (which is treated as one cell) |
| 98 | + const cells = generateCells(this.configService.getSettings().datascience, args.code, args.file, args.line, true, args.id); |
| 99 | + const codeCell = cells.find(c => c.data.cell_type === 'code'); |
| 100 | + if (codeCell) { |
| 101 | + // When the user adds new code, we know the execution count is increasing |
| 102 | + this.executionCount += 1; |
| 103 | + |
| 104 | + // Skip hash on unknown file though |
| 105 | + if (args.file !== Identifiers.EmptyFileName) { |
| 106 | + this.addCellHash(concatMultilineString(codeCell.data.source), codeCell.line, codeCell.file, this.executionCount); |
| 107 | + } |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + private onChangedDocument(e: TextDocumentChangeEvent) { |
| 113 | + // See if the document is in our list of docs to watch |
| 114 | + const perFile = this.hashes.get(e.document.fileName); |
| 115 | + if (perFile) { |
| 116 | + // Apply the content changes to the file's cells. |
| 117 | + let prevText = e.document.getText(); |
| 118 | + e.contentChanges.forEach(c => { |
| 119 | + prevText = this.handleContentChange(prevText, c, perFile); |
| 120 | + }); |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + private handleContentChange(docText: string, c: TextDocumentContentChangeEvent, hashes: IRangedCellHash[]) : string { |
| 125 | + // First compute the number of lines that changed |
| 126 | + const lineDiff = c.text.split('\n').length - docText.substr(c.rangeOffset, c.rangeLength).split('\n').length; |
| 127 | + const offsetDiff = c.text.length - c.rangeLength; |
| 128 | + |
| 129 | + // Compute the inclusive offset that is changed by the cell. |
| 130 | + const endChangedOffset = c.rangeLength <= 0 ? c.rangeOffset : c.rangeOffset + c.rangeLength - 1; |
| 131 | + |
| 132 | + // Also compute the text of the document with the change applied |
| 133 | + const appliedText = this.applyChange(docText, c); |
| 134 | + |
| 135 | + hashes.forEach(h => { |
| 136 | + // See how this existing cell compares to the change |
| 137 | + if (h.endOffset < c.rangeOffset) { |
| 138 | + // No change. This cell is entirely before the change |
| 139 | + } else if (h.startOffset > endChangedOffset) { |
| 140 | + // This cell is after the text that got replaced. Adjust its start/end lines |
| 141 | + h.line += lineDiff; |
| 142 | + h.endLine += lineDiff; |
| 143 | + h.startOffset += offsetDiff; |
| 144 | + h.endOffset += offsetDiff; |
| 145 | + } else { |
| 146 | + // Cell intersects. Mark as deleted if not exactly the same (user could type over the exact same values) |
| 147 | + h.deleted = appliedText.substr(h.startOffset, h.endOffset - h.startOffset) !== h.realCode; |
| 148 | + } |
| 149 | + }); |
| 150 | + |
| 151 | + return appliedText; |
| 152 | + } |
| 153 | + |
| 154 | + private applyChange(docText: string, c: TextDocumentContentChangeEvent) : string { |
| 155 | + const before = docText.substr(0, c.rangeOffset); |
| 156 | + const after = docText.substr(c.rangeOffset + c.rangeLength); |
| 157 | + return `${before}${c.text}${after}`; |
| 158 | + } |
| 159 | + |
| 160 | + private addCellHash(code: string, startLine: number, file: string, expectedCount: number) { |
| 161 | + // Find the text document that matches. We need more information than |
| 162 | + // the add code gives us |
| 163 | + const doc = this.documentManager.textDocuments.find(d => d.fileName === file); |
| 164 | + if (doc) { |
| 165 | + // The code we get is not actually what's in the document. The interactiveWindow massages it somewhat. |
| 166 | + // We need the real code so that we can match document edits later. |
| 167 | + const split = code.split('\n'); |
| 168 | + const lineCount = split.length; |
| 169 | + const line = doc.lineAt(startLine); |
| 170 | + const endLine = doc.lineAt(Math.min(startLine + lineCount - 1, doc.lineCount - 1)); |
| 171 | + const startOffset = doc.offsetAt(new Position(startLine, 0)); |
| 172 | + const endOffset = doc.offsetAt(endLine.rangeIncludingLineBreak.end); |
| 173 | + const realCode = doc.getText(new Range(line.range.start, endLine.rangeIncludingLineBreak.end)); |
| 174 | + const hash : IRangedCellHash = { |
| 175 | + hash: hashjs.sha1().update(code).digest('hex').substr(0, 12), |
| 176 | + line: startLine + 1, |
| 177 | + endLine: startLine + lineCount, |
| 178 | + executionCount: expectedCount, |
| 179 | + startOffset, |
| 180 | + endOffset, |
| 181 | + deleted: false, |
| 182 | + code, |
| 183 | + realCode |
| 184 | + }; |
| 185 | + |
| 186 | + let list = this.hashes.get(file); |
| 187 | + if (!list) { |
| 188 | + list = []; |
| 189 | + } |
| 190 | + |
| 191 | + // Figure out where to put the item in the list |
| 192 | + let inserted = false; |
| 193 | + for (let i = 0; i < list.length && !inserted; i += 1) { |
| 194 | + const pos = list[i]; |
| 195 | + if (hash.line >= pos.line && hash.line <= pos.endLine) { |
| 196 | + // Stick right here. This is either the same cell or a cell that overwrote where |
| 197 | + // we were. |
| 198 | + list.splice(i, 1, hash); |
| 199 | + inserted = true; |
| 200 | + } else if (pos.line > hash.line) { |
| 201 | + // This item comes just after the cell we're inserting. |
| 202 | + list.splice(i, 0, hash); |
| 203 | + inserted = true; |
| 204 | + } |
| 205 | + } |
| 206 | + if (!inserted) { |
| 207 | + list.push(hash); |
| 208 | + } |
| 209 | + this.hashes.set(file, list); |
| 210 | + } |
| 211 | + } |
| 212 | +} |
0 commit comments