forked from iangreenleaf/Scisr
-
Notifications
You must be signed in to change notification settings - Fork 0
/
File.php
353 lines (329 loc) · 13 KB
/
File.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
<?php
/**
* A single file, as Scisr sees it.
*
* Used to track edits and then make the actual changes.
*
* @todo Perhaps this could extend PHP_CodeSniffer_File. This would enable us to
* move a lot of the responsibilities of Scisr_ChangeRegistry into this class,
* which probably makes more sense in the end. And it ought to let us nicely
* encapsulate the business of caching parsed info (ticket #4).
*/
class Scisr_File
{
/**
* The path to the file
* @var string
*/
public $filename;
/**
* Stores the pending changes to this file.
* A 2D array by line number, then column number.
* @var array
*/
public $changes = array();
/**
* Stores the pending tentative changes to this file.
* These changes will only be made if we are in aggressive mode.
* A 2D array by line number, then column number.
* @var array
*/
public $tentativeChanges = array();
/**
* A new filename.
* If not null, indicates this file is to be renamed.
* @var string|null
*/
private $_newName = null;
/**
* Changes that were suggested but are not actually being made to this file.
* @var array
*/
private $_changesNotProcessed = array();
/**
* Create a new Scisr_File
* @param string $filename the path to the file
*/
public function __construct($filename)
{
$this->filename = self::getAbsolutePath($filename);
}
/**
* Is a path explicitly relative?
* Paths like "./foo" are explicitly relative. Paths like "/x/foo" clearly
* are not, and paths like "x/foo" are considered implicitly relative but
* not explicitly so.
* @return boolean true if the path is explicitly relative
*/
public static function isExplicitlyRelative($path) {
return (preg_match('_^[.]+/_', $path) === 1);
}
/**
* Calculate the absolute path for a file
* @param string $filename a relative or absolute path to a file
* @param string $currDir an absolute path to the current directory, which
* will be used as the base of a relative path. Defaults to the current
* working directory.
* @return string the absolute path to the file
*/
public static function getAbsolutePath($filename, $currDir=null)
{
// If it's not an absolute path already, calculate it from our current dir
if ($filename{0} != '/') {
if ($currDir === null) {
$currDir = getcwd();
}
$filename = $currDir . '/' . $filename;
}
return self::normalizePath($filename);
}
protected static function normalizePath($path)
{
$pieces = explode('/', $path);
// Filter out empty items
$pieces = array_filter($pieces, create_function('$s', 'return ($s !== "");'));
// array_filter left us with wonky keys, which will confuse array_splice, so rekey
$pieces = array_values($pieces);
// Now look for . and ..
while (($i = array_search('.', $pieces)) !== false) {
array_splice($pieces, $i, 1);
}
while (($i = array_search('..', $pieces)) !== false) {
array_splice($pieces, $i - 1, 2);
}
return '/' . implode('/', $pieces);
}
/**
* Add a pending edit
*
* The edit will not actually be applied until you run {@link process()}.
*
* @param int $line the line number of the edit
* @param int $column the column number where the edit begins
* @param int $length length of the text to remove
* @param string $replacement the text to replace the removed text with
*/
public function addEdit($line, $column, $length, $replacement, $tentative)
{
if ($tentative) {
$this->tentativeChanges[$line][$column] = array($length, $replacement);
} else {
$this->changes[$line][$column] = array($length, $replacement);
}
}
/**
* Set a pending file rename
*
* Will not actually be applied until you run {@link process()}.
*
* @param string $newName the new name for this file
*/
public function rename($newName)
{
$this->_newName = self::getAbsolutePath($newName);
}
/**
* Process all pending edits to the file
* @param int $mode a constant from {@link ScisrRunner} indicating what mode we are running in
* @return boolean true if this file was actually changed
*/
public function process($mode)
{
if ($mode === ScisrRunner::MODE_AGGRESSIVE) {
// In aggressive mode, all tentative changes get slated for application
$this->changes = self::mergeChanges($this->tentativeChanges, $this->changes);
$this->tentativeChanges = array();
} else {
if ($mode == ScisrRunner::MODE_TIMID) {
// In timid mode, all changes become tentative
$this->tentativeChanges = self::mergeChanges($this->tentativeChanges, $this->changes);
$this->changes = array();
}
ksort($this->tentativeChanges);
// We need to get rid of multiple tentative changes and any that are covered by actual changes
foreach ($this->tentativeChanges as $lineNo => $changes) {
$realChanges = (isset($this->changes[$lineNo]) ? $this->changes[$lineNo] : array());
$this->_changesNotProcessed[$lineNo] = $this->reconcileNonEdits($changes, $realChanges);
}
}
// Sort by columns and then by lines
foreach ($this->changes as $key => &$array) {
ksort($array);
}
ksort($this->changes);
// Get the file contents and open it for writing
$contents = file($this->filename);
$output = array();
// Loop through the file contents, making changes
foreach ($contents as $i => $line) {
$lineNo = $i + 1;
if (isset($this->changes[$lineNo])) {
$lineEdits = $this->reconcileEdits($this->changes[$lineNo]);
// Track the net column change caused by edits to this line so far
$lineOffsetDelta = 0;
foreach ($lineEdits as $col => $edit) {
$col += $lineOffsetDelta;
$length = $edit[0];
$replacement = $edit[1];
// Update the net offset with the change caused by this edit
$lineOffsetDelta += strlen($replacement) - $length;
// Make the change
$line = substr_replace($line, $replacement, $col - 1, $length);
}
}
// Save the resulting line to be written to the file
$output[] = $line;
}
// Write all output to the file
file_put_contents($this->filename, $output);
// If there's a rename pending, do it
if ($this->_newName !== null && $mode !== ScisrRunner::MODE_TIMID) {
$dir = dirname($this->_newName);
if (!is_dir($dir)) {
$success = mkdir($dir, 0775, true);
if (!$success) {
$err = "Could not create new directory ($dir)";
throw new RuntimeException($err);
}
}
$success = rename($this->filename, $this->_newName);
if (!$success) {
$err = "Could not rename file ($this->filename => $this->_newName)";
throw new RuntimeException($err);
}
}
return (count($this->changes) > 0);
}
/**
* Merge two arrays of changes.
* None of the php array functions quite hits the combination of recursion
* and overwriting that we need here, so I rolled my own.
* @param array $changes1 the first array of changes
* @param array $changes2 the second array of changes
* @return array the merged changes.
*/
protected static function mergeChanges($changes1, $changes2)
{
$result = array();
$keys = array_merge(array_keys($changes1), array_keys($changes2));
foreach ($keys as $key) {
if (!isset($changes1[$key])) {
$result[$key] = $changes2[$key];
} else if (!isset($changes2[$key])) {
$result[$key] = $changes1[$key];
} else {
$result[$key] = $changes2[$key] + $changes1[$key];
}
}
return $result;
}
/**
* Get the number of edits which were not applied to this file
* @return int
*/
public function getNumChangesNotProcessed()
{
$sum = 0;
foreach ($this->_changesNotProcessed as $line) {
$sum += count($line);
}
return $sum;
}
/**
* Get a list of lines that had edits which were not actually applied
* @return array
*/
public function getLinesNotProcessed()
{
return array_keys($this->_changesNotProcessed);
}
/**
* Reconcile edit requests for a line.
* Handles benign conflicts automatically.
* @param array an array of edit requests for a line, as stored in $this->changes
* @return array the array of all edit requests to act on
*/
private function reconcileEdits($lineChanges)
{
$goodChanges = array();
foreach ($lineChanges as $startCol => $edit) {
$goodChanges = $this->checkNewEditForConflicts($edit, $startCol, $goodChanges);
}
return $goodChanges;
}
/**
* Try to insert a new edit request into an existing list of edit requests.
* May remove an existing edit or not insert the new edit if a resolvable conflict exists.
* @param array $newEdit an edit request, as stored in $this->changes[][]
* @param int $startCol the start column of $newEdit
* @param array $previousEdits the list of existing edit requests
* @return array a new list of edit requests with the new edit incorporated
*/
private function checkNewEditForConflicts($newEdit, $startCol, $previousEdits)
{
$length = $newEdit[0];
$endCol = $startCol + $length - 1;
foreach ($previousEdits as $oldStartCol => $oldEdit) {
$oldEndCol = $oldStartCol + $oldEdit[0] - 1;
// Ignore unless this edit request conflicts
if ($oldEndCol < $startCol || $oldStartCol > $endCol) {
continue;
}
if ($startCol >= $oldStartCol && $endCol <= $oldEndCol) {
// Previous edit encompasses all of this edit, ignore this edit
return $previousEdits;
} else if ($startCol <= $oldStartCol && $endCol >= $oldEndCol) {
// This edit encompasses all of previous edit, remove previous edit
$newEditsList = array_splice($previousEdits, $oldStartCol, 1);
return $this->checkNewEditForConflicts($newEdit, $startCol, $newEditsList);
} else {
// Edit requests are staggered, no correct resolution is possible.
// I don't expect this to ever happen unless a developer makes a mistake,
// so we'll just abort messily
$err = "We've encountered conflicting edit requests. Cannot continue.";
throw new RuntimeException($err);
}
}
$previousEdits[$startCol] = $newEdit;
return $previousEdits;
}
/**
* Reconcile edit notifications for a line, always removing from the first array
* @param array $lineChanges an array of edit requests for a line
* @param array $masterChanges an array of edit requests for the line that
* will always win if there is a conflict
* @return array all edits contained in $lineChanges that did not conflict
* with other items in that list or in $masterChanges
*/
private function reconcileNonEdits($lineChanges, $masterChanges)
{
$goodChanges = array();
foreach ($lineChanges as $startCol => $edit) {
if ($this->doesEditConflict($edit, $startCol, $masterChanges)) {
continue;
}
$goodChanges = $this->checkNewEditForConflicts($edit, $startCol, $goodChanges);
}
return $goodChanges;
}
/**
* See if an edit conflicts with the given list of edit requests
* @param array $newEdit an edit request, as stored in $this->changes[][]
* @param int $startCol the start column of $newEdit
* @param array $existingEdits the list of existing edit requests
* @return boolean true if $newEdit conflicts with something in $existingEdits
*/
private function doesEditConflict($newEdit, $startCol, $existingEdits)
{
$length = $newEdit[0];
$endCol = $startCol + $length - 1;
foreach ($existingEdits as $oldStartCol => $oldEdit) {
$oldEndCol = $oldStartCol + $oldEdit[0] - 1;
// Ignore unless this edit request conflicts
if ($oldEndCol >= $startCol && $oldStartCol <= $endCol) {
return true;
}
}
return false;
}
}