diff --git a/Atom.xcodeproj/project.pbxproj b/Atom.xcodeproj/project.pbxproj index 908ad9349..bac99d777 100644 --- a/Atom.xcodeproj/project.pbxproj +++ b/Atom.xcodeproj/project.pbxproj @@ -1142,6 +1142,7 @@ LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/System/Library/Frameworks", "\"$(SRCROOT)/frameworks\"", + "\"$(SDKROOT)/usr/lib/system\"", ); OTHER_CFLAGS = ( "-fno-strict-aliasing", @@ -1188,6 +1189,7 @@ LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/System/Library/Frameworks", "\"$(SRCROOT)/frameworks\"", + "\"$(SDKROOT)/usr/lib/system\"", ); OTHER_CFLAGS = "-fno-strict-aliasing"; OTHER_LDFLAGS = ( diff --git a/Atom.xcodeproj/xcshareddata/xcschemes/atom-debug-test.xcscheme b/Atom.xcodeproj/xcshareddata/xcschemes/atom-debug-test.xcscheme new file mode 100644 index 000000000..0e7d1aa86 --- /dev/null +++ b/Atom.xcodeproj/xcshareddata/xcschemes/atom-debug-test.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Atom/src/AtomController.mm b/Atom/src/AtomController.mm index a8c4373c5..4a8adc0fb 100644 --- a/Atom/src/AtomController.mm +++ b/Atom/src/AtomController.mm @@ -208,8 +208,8 @@ void AppGetBrowserSettings(CefBrowserSettings& settings) { settings.databases_disabled = false; settings.application_cache_disabled = false; settings.webgl_disabled = false; - settings.accelerated_compositing_enabled = false; - settings.threaded_compositing_enabled = false; + settings.accelerated_compositing_enabled = true; + settings.threaded_compositing_enabled = true; settings.accelerated_layers_disabled = false; settings.accelerated_video_disabled = false; settings.accelerated_2d_canvas_disabled = false; diff --git a/Atom/src/native_handler.mm b/Atom/src/native_handler.mm index 8fa2feb11..e900e37b7 100644 --- a/Atom/src/native_handler.mm +++ b/Atom/src/native_handler.mm @@ -4,9 +4,12 @@ #import "AtomController.h" #import "client_handler.h" #import "PathWatcher.h" +#import #import #import +#import + #define MY_EXCEPTION_TRY @try { #define MY_EXCEPTION_HANDLE } @catch (NSException *localException) {} @@ -15,10 +18,22 @@ NSString *stringFromCefV8Value(const CefRefPtr& value) { return [NSString stringWithUTF8String:cc_value.c_str()]; } +void throwException(const CefRefPtr& global, CefRefPtr& exception, NSString *message) { + CefV8ValueList arguments; + CefRefPtr retval; + CefRefPtr e; + + message = [message stringByAppendingFormat:@"\n%s", exception->GetMessage().ToString().c_str()]; + arguments.push_back(CefV8Value::CreateString(std::string([message UTF8String], [message lengthOfBytesUsingEncoding:NSUTF8StringEncoding]))); + + CefRefPtr console = global->GetValue("console"); + console->GetValue("error")->ExecuteFunction(console, arguments, retval, e, false); +} + NativeHandler::NativeHandler() : CefV8Handler() { std::string extensionCode = "var $native = {}; (function() {"; - const char *functionNames[] = {"exists", "alert", "read", "write", "absolute", "list", "isFile", "isDirectory", "remove", "asyncList", "open", "openDialog", "quit", "writeToPasteboard", "readFromPasteboard", "showDevTools", "toggleDevTools", "newWindow", "saveDialog", "exit", "watchPath", "unwatchPath", "makeDirectory", "move", "moveToTrash", "reload", "lastModified", "md5ForPath"}; + const char *functionNames[] = {"exists", "alert", "read", "write", "absolute", "list", "isFile", "isDirectory", "remove", "asyncList", "open", "openDialog", "quit", "writeToPasteboard", "readFromPasteboard", "showDevTools", "toggleDevTools", "newWindow", "saveDialog", "exit", "watchPath", "unwatchPath", "makeDirectory", "move", "moveToTrash", "reload", "lastModified", "md5ForPath", "exec"}; NSUInteger arrayLength = sizeof(functionNames) / sizeof(const char *); for (NSUInteger i = 0; i < arrayLength; i++) { std::string functionName = std::string(functionNames[i]); @@ -419,5 +434,96 @@ bool NativeHandler::Execute(const CefString& name, retval = CefV8Value::CreateString([hash UTF8String]); return true; } + else if (name == "exec") { + NSString *command = stringFromCefV8Value(arguments[0]); + CefRefPtr options = arguments[1]; + CefRefPtr callback = arguments[2]; + + NSTask *task = [[NSTask alloc] init]; + [task setLaunchPath:@"/bin/sh"]; + [task setStandardInput:[NSFileHandle fileHandleWithNullDevice]]; + [task setArguments:[NSArray arrayWithObjects:@"-l", @"-c", command, nil]]; + + NSPipe *stdout = [NSPipe pipe]; + NSPipe *stderr = [NSPipe pipe]; + [task setStandardOutput:stdout]; + [task setStandardError:stderr]; + + CefRefPtr context = CefV8Context::GetCurrentContext(); + void (^outputHandle)(NSFileHandle *fileHandle, CefRefPtr function) = nil; + void (^taskTerminatedHandle)() = nil; + + outputHandle = ^(NSFileHandle *fileHandle, CefRefPtr function) { + context->Enter(); + + NSData *data = [fileHandle availableData]; + NSString *contents = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + + CefV8ValueList args; + CefRefPtr retval = CefV8Value::CreateBool(YES); + CefRefPtr e; + + args.push_back(CefV8Value::CreateString(std::string([contents UTF8String], [contents lengthOfBytesUsingEncoding:NSUTF8StringEncoding]))); + function->ExecuteFunction(function, args, retval, e, false); + + if (e.get()) { + throwException(context->GetGlobal(), e, @"Error thrown in OutputHandle"); + } + + [contents release]; + context->Exit(); + }; + + taskTerminatedHandle = ^() { + context->Enter(); + NSString *output = [[NSString alloc] initWithData:[[stdout fileHandleForReading] readDataToEndOfFile] encoding:NSUTF8StringEncoding]; + NSString *errorOutput = [[NSString alloc] initWithData:[[task.standardError fileHandleForReading] readDataToEndOfFile] encoding:NSUTF8StringEncoding]; + + CefV8ValueList args; + CefRefPtr retval; + CefRefPtr e; + + args.push_back(CefV8Value::CreateInt([task terminationStatus])); + args.push_back(CefV8Value::CreateString([output UTF8String])); + args.push_back(CefV8Value::CreateString([errorOutput UTF8String])); + + callback->ExecuteFunction(callback, args, retval, e, false); + + if (e.get()) { + throwException(context->GetGlobal(), e, @"Error thrown in TaskTerminatedHandle"); + } + + context->Exit(); + + stdout.fileHandleForReading.writeabilityHandler = nil; + stderr.fileHandleForReading.writeabilityHandler = nil; + }; + + task.terminationHandler = ^(NSTask *) { + dispatch_sync(dispatch_get_main_queue(), taskTerminatedHandle); + }; + + CefRefPtr stdoutFunction = options->GetValue("stdout"); + if (stdoutFunction->IsFunction()) { + stdout.fileHandleForReading.writeabilityHandler = ^(NSFileHandle *fileHandle) { + dispatch_sync(dispatch_get_main_queue(), ^() { + outputHandle(fileHandle, stdoutFunction); + }); + }; + } + + CefRefPtr stderrFunction = options->GetValue("stderr"); + if (stderrFunction->IsFunction()) { + stderr.fileHandleForReading.writeabilityHandler = ^(NSFileHandle *fileHandle) { + dispatch_sync(dispatch_get_main_queue(), ^() { + outputHandle(fileHandle, stderrFunction); + }); + }; + } + + [task launch]; + + return true; + } return false; }; \ No newline at end of file diff --git a/benchmark/benchmark-suite.coffee b/benchmark/benchmark-suite.coffee index 1c70248aa..db66ec394 100644 --- a/benchmark/benchmark-suite.coffee +++ b/benchmark/benchmark-suite.coffee @@ -17,6 +17,10 @@ describe "editor.", -> window.shutdown() delete atom.rootViewStates[$windowNumber] + describe "opening-buffers.", -> + benchmark "300-line-file.", -> + buffer = rootView.project.bufferForPath('medium.coffee') + describe "empty-file.", -> benchmark "insert-delete", -> editor.insertText('x') @@ -24,7 +28,7 @@ describe "editor.", -> describe "300-line-file.", -> beforeEach -> - editor.edit rootView.project.open('medium.coffee') + editor.edit rootView.project.buildEditSessionForPath('medium.coffee') describe "at-begining.", -> benchmark "insert-delete", -> @@ -45,11 +49,11 @@ describe "editor.", -> describe "9000-line-file.", -> benchmark "opening.", 5, -> - editor.edit rootView.project.open('huge.js') + editor.edit rootView.project.buildEditSessionForPath('huge.js') describe "after-opening.", -> beforeEach -> - editor.edit rootView.project.open('huge.js') + editor.edit rootView.project.buildEditSessionForPath('huge.js') benchmark "moving-to-eof.", 1, -> editor.moveCursorToBottom() diff --git a/spec/app/buffer-spec.coffee b/spec/app/buffer-spec.coffee index 1567813c1..d66fb62c2 100644 --- a/spec/app/buffer-spec.coffee +++ b/spec/app/buffer-spec.coffee @@ -11,11 +11,11 @@ describe 'Buffer', -> buffer = new Buffer(filePath) afterEach -> - buffer.destroy() + buffer?.release() describe 'constructor', -> beforeEach -> - buffer.destroy() + buffer.release() describe "when given a path", -> describe "when a file exists for the path", -> @@ -31,9 +31,9 @@ describe 'Buffer', -> describe "when no file exists for the path", -> it "throws an exception", -> + buffer = null filePath = "does-not-exist.txt" expect(fs.exists(filePath)).toBeFalsy() - expect(-> new Buffer(filePath)).toThrow() describe "when no path is given", -> @@ -82,8 +82,8 @@ describe 'Buffer', -> beforeEach -> path = "/tmp/tmp.txt" fs.write(path, "first") - buffer.destroy() - buffer = new Buffer(path) + buffer.release() + buffer = new Buffer(path).retain() afterEach -> fs.remove(path) @@ -146,7 +146,10 @@ describe 'Buffer', -> describe ".isModified()", -> beforeEach -> buffer.destroy() + waitsFor "file to be removed", -> + not bufferToDelete.getPath() + describe ".isModified()", -> it "returns true when user changes buffer", -> expect(buffer.isModified()).toBeFalsy() buffer.insert([0,0], "hi") @@ -155,6 +158,7 @@ describe 'Buffer', -> it "returns false after modified buffer is saved", -> filePath = "/tmp/atom-tmp-file" fs.write(filePath, '') + buffer.release() buffer = new Buffer(filePath) expect(buffer.isModified()).toBe false @@ -298,7 +302,7 @@ describe 'Buffer', -> describe ".save()", -> beforeEach -> - buffer.destroy() + buffer.release() describe "when the buffer has a path", -> filePath = null @@ -349,33 +353,34 @@ describe 'Buffer', -> expect(buffer.getText()).toBe(fileContents) describe ".saveAs(path)", -> - filePath = null + [filePath, saveAsBuffer] = [] - beforeEach -> - buffer.destroy() + afterEach -> + saveAsBuffer.release() it "saves the contents of the buffer to the path", -> filePath = '/tmp/temp.txt' fs.remove filePath if fs.exists(filePath) - buffer = new Buffer() + saveAsBuffer = new Buffer().retain() eventHandler = jasmine.createSpy('eventHandler') - buffer.on 'path-change', eventHandler + saveAsBuffer.on 'path-change', eventHandler - buffer.setText 'Buffer contents!' - buffer.saveAs(filePath) + saveAsBuffer.setText 'Buffer contents!' + saveAsBuffer.saveAs(filePath) expect(fs.read(filePath)).toEqual 'Buffer contents!' - expect(eventHandler).toHaveBeenCalledWith(buffer) + expect(eventHandler).toHaveBeenCalledWith(saveAsBuffer) it "stops listening to events on previous path and begins listening to events on new path", -> originalPath = "/tmp/original.txt" newPath = "/tmp/new.txt" fs.write(originalPath, "") - buffer = new Buffer(originalPath) + + saveAsBuffer = new Buffer(originalPath).retain() changeHandler = jasmine.createSpy('changeHandler') - buffer.on 'change', changeHandler - buffer.saveAs(newPath) + saveAsBuffer.on 'change', changeHandler + saveAsBuffer.saveAs(newPath) expect(changeHandler).not.toHaveBeenCalled() fs.write(originalPath, "should not trigger buffer event") @@ -597,3 +602,23 @@ describe 'Buffer', -> expect(buffer.positionForCharacterIndex(30)).toEqual [1, 0] expect(buffer.positionForCharacterIndex(61)).toEqual [2, 0] expect(buffer.positionForCharacterIndex(408)).toEqual [12, 2] + + describe "anchors", -> + [anchor, destroyHandler] = [] + + beforeEach -> + destroyHandler = jasmine.createSpy("destroyHandler") + anchor = buffer.addAnchorAtPosition([4, 25]) + anchor.on 'destroy', destroyHandler + + describe "when a buffer change precedes an anchor", -> + it "moves the anchor in accordance with the change", -> + buffer.delete([[3, 0], [4, 10]]) + expect(anchor.getBufferPosition()).toEqual [3, 15] + expect(destroyHandler).not.toHaveBeenCalled() + + describe "when a buffer change surrounds an anchor", -> + it "destroys the anchor", -> + buffer.delete([[3, 0], [5, 0]]) + expect(destroyHandler).toHaveBeenCalled() + expect(buffer.getAnchors().indexOf(anchor)).toBe -1 diff --git a/spec/app/edit-session-spec.coffee b/spec/app/edit-session-spec.coffee index a3ecf9f2b..796c4905c 100644 --- a/spec/app/edit-session-spec.coffee +++ b/spec/app/edit-session-spec.coffee @@ -7,7 +7,7 @@ describe "EditSession", -> beforeEach -> buffer = new Buffer() - editSession = fixturesProject.open('sample.js', autoIndent: false) + editSession = fixturesProject.buildEditSessionForPath('sample.js', autoIndent: false) buffer = editSession.buffer lineLengths = buffer.getLines().map (line) -> line.length @@ -523,6 +523,47 @@ describe "EditSession", -> editSession.selectWord() expect(editSession.getSelectedText()).toBe '' + describe ".setSelectedBufferRanges(ranges)", -> + it "clears existing selections and creates selections for each of the given ranges", -> + editSession.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + expect(editSession.getSelectedBufferRanges()).toEqual [[[2, 2], [3, 3]], [[4, 4], [5, 5]]] + + editSession.setSelectedBufferRanges([[[5, 5], [6, 6]]]) + expect(editSession.getSelectedBufferRanges()).toEqual [[[5, 5], [6, 6]]] + + it "merges intersecting selections", -> + editSession.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) + expect(editSession.getSelectedBufferRanges()).toEqual [[[2, 2], [5, 5]]] + + it "recyles existing selection instances", -> + selection = editSession.getSelection() + editSession.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + + [selection1, selection2] = editSession.getSelections() + expect(selection1).toBe selection + expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] + + describe "when the preserveFolds option is false (the default)", -> + it "removes folds that contain the selections", -> + editSession.setSelectedBufferRange([[0,0], [0,0]]) + editSession.createFold(1, 4) + editSession.createFold(2, 3) + editSession.createFold(6, 8) + editSession.createFold(10, 11) + + editSession.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 6], [7, 7]]]) + expect(editSession.lineForScreenRow(1).fold).toBeUndefined() + expect(editSession.lineForScreenRow(2).fold).toBeUndefined() + expect(editSession.lineForScreenRow(6).fold).toBeUndefined() + expect(editSession.lineForScreenRow(10).fold).toBeDefined() + + describe "when the preserve folds option is true", -> + it "does not remove folds that contain the selections", -> + editSession.setSelectedBufferRange([[0,0], [0,0]]) + editSession.createFold(1, 4) + editSession.setSelectedBufferRanges([[[2, 2], [3, 3]]], preserveFolds: true) + expect(editSession.lineForScreenRow(1).fold).toBeDefined() + describe "when the cursor is moved while there is a selection", -> makeSelection = -> selection.setBufferRange [[1, 2], [1, 5]] @@ -870,8 +911,8 @@ describe "EditSession", -> describe "when the selection ends on a folded line", -> it "destroys the fold", -> - editSession.toggleFoldAtBufferRow(4) editSession.setSelectedBufferRange([[3,0], [4,0]]) + editSession.toggleFoldAtBufferRow(4) editSession.backspace() expect(buffer.lineForRow(3)).toBe " return sort(left).concat(pivot).concat(sort(right));" @@ -955,12 +996,12 @@ describe "EditSession", -> describe "when the cursor is on a folded line", -> it "removes the lines contained by the fold", -> + editSession.setSelectedBufferRange([[2, 0], [2, 0]]) editSession.createFold(2,4) editSession.createFold(2,6) oldLine7 = buffer.lineForRow(7) oldLine8 = buffer.lineForRow(8) - editSession.setSelectedBufferRange([[2, 0], [2, 0]]) editSession.delete() expect(editSession.lineForScreenRow(2).text).toBe oldLine7 expect(editSession.lineForScreenRow(3).text).toBe oldLine8 @@ -1265,23 +1306,20 @@ describe "EditSession", -> selections = editSession.getSelections() expect(buffer.lineForRow(1)).toBe ' var = function( {' - expect(selections[0].getBufferRange()).toEqual [[1, 6], [1, 6]] - expect(selections[1].getBufferRange()).toEqual [[1, 17], [1, 17]] + + expect(editSession.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 17], [1, 17]]] editSession.undo() - expect(selections[0].getBufferRange()).toEqual [[1, 6], [1, 6]] - expect(selections[1].getBufferRange()).toEqual [[1, 18], [1, 18]] + expect(editSession.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] editSession.undo() - expect(selections[0].getBufferRange()).toEqual [[1, 6], [1, 10]] - expect(selections[1].getBufferRange()).toEqual [[1, 22], [1, 27]] + expect(editSession.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 10]], [[1, 22], [1, 27]]] editSession.redo() - expect(selections[0].getBufferRange()).toEqual [[1, 6], [1, 6]] - expect(selections[1].getBufferRange()).toEqual [[1, 18], [1, 18]] + expect(editSession.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] it "restores selected ranges even when the change occurred in another edit session", -> - otherEditSession = fixturesProject.open(editSession.getPath()) + otherEditSession = fixturesProject.buildEditSessionForPath(editSession.getPath()) otherEditSession.setSelectedBufferRange([[2, 2], [3, 3]]) otherEditSession.delete() @@ -1324,14 +1362,12 @@ describe "EditSession", -> [cursor1, cursor2, cursor3] = editSession.getCursors() expect(editSession.getCursors().length).toBe 3 - editSession.backspace() + buffer.delete([[0, 0], [0, 1]]) + + expect(editSession.getCursors().length).toBe 2 expect(editSession.getCursors()).toEqual [cursor1, cursor3] expect(cursor1.getBufferPosition()).toEqual [0,0] - expect(cursor3.getBufferPosition()).toEqual [1,0] - - editSession.insertText "x" - expect(editSession.lineForBufferRow(0)).toBe "xar quicksort = function () {" - expect(editSession.lineForBufferRow(1)).toBe "x var sort = function(items) {" + expect(cursor3.getBufferPosition()).toEqual [1,1] describe "folding", -> describe "structural folding", -> diff --git a/spec/app/editor-spec.coffee b/spec/app/editor-spec.coffee index 3f4316504..6600ca150 100644 --- a/spec/app/editor-spec.coffee +++ b/spec/app/editor-spec.coffee @@ -14,7 +14,7 @@ describe "Editor", -> getLineHeight = -> return cachedLineHeight if cachedLineHeight? - editorForMeasurement = new Editor(editSession: rootView.project.open('sample.js')) + editorForMeasurement = new Editor(editSession: rootView.project.buildEditSessionForPath('sample.js')) editorForMeasurement.attachToDom() cachedLineHeight = editorForMeasurement.lineHeight editorForMeasurement.remove() @@ -49,7 +49,7 @@ describe "Editor", -> rootView.height(8 * editor.lineHeight) rootView.width(50 * editor.charWidth) - editor.edit(rootView.project.open('two-hundred.txt')) + editor.edit(rootView.project.buildEditSessionForPath('two-hundred.txt')) editor.setCursorScreenPosition([5, 1]) editor.scrollTop(1.5 * editor.lineHeight) editor.scrollView.scrollLeft(44) @@ -113,7 +113,7 @@ describe "Editor", -> describe ".remove()", -> it "removes subscriptions from all edit session buffers", -> previousEditSession = editor.activeEditSession - otherEditSession = rootView.project.open(rootView.project.resolve('sample.txt')) + otherEditSession = rootView.project.buildEditSessionForPath(rootView.project.resolve('sample.txt')) expect(previousEditSession.buffer.subscriptionCount()).toBeGreaterThan 1 editor.edit(otherEditSession) @@ -125,7 +125,7 @@ describe "Editor", -> describe "when 'close' is triggered", -> it "closes active edit session and loads next edit session", -> - editor.edit(rootView.project.open()) + editor.edit(rootView.project.buildEditSessionForPath()) editSession = editor.activeEditSession spyOn(editSession, 'destroy').andCallThrough() spyOn(editor, "remove").andCallThrough() @@ -161,7 +161,7 @@ describe "Editor", -> otherEditSession = null beforeEach -> - otherEditSession = rootView.project.open() + otherEditSession = rootView.project.buildEditSessionForPath() describe "when the edit session wasn't previously assigned to this editor", -> it "adds edit session to editor", -> @@ -197,10 +197,10 @@ describe "Editor", -> beforeEach -> session0 = editor.activeEditSession - editor.edit(rootView.project.open('sample.txt')) + editor.edit(rootView.project.buildEditSessionForPath('sample.txt')) session1 = editor.activeEditSession - editor.edit(rootView.project.open('two-hundred.txt')) + editor.edit(rootView.project.buildEditSessionForPath('two-hundred.txt')) session2 = editor.activeEditSession describe ".setActiveEditSessionIndex(index)", -> @@ -277,7 +277,7 @@ describe "Editor", -> describe "when the current buffer has no path", -> selectedFilePath = null beforeEach -> - editor.edit(rootView.project.open()) + editor.edit(rootView.project.buildEditSessionForPath()) expect(editor.getPath()).toBeUndefined() editor.getBuffer().setText 'Save me to a new path' @@ -357,7 +357,7 @@ describe "Editor", -> spyOn(editor, 'pane').andReturn(fakePane) it "calls the corresponding split method on the containing pane with a new editor containing a copy of the active edit session", -> - editor.edit project.open("sample.txt") + editor.edit project.buildEditSessionForPath("sample.txt") editor.splitUp() expect(fakePane.splitUp).toHaveBeenCalled() [newEditor] = fakePane.splitUp.argsForCall[0] @@ -404,7 +404,7 @@ describe "Editor", -> it "emits event when editor receives a new buffer", -> eventHandler = jasmine.createSpy('eventHandler') editor.on 'editor-path-change', eventHandler - editor.edit(rootView.project.open(path)) + editor.edit(rootView.project.buildEditSessionForPath(path)) expect(eventHandler).toHaveBeenCalled() it "stops listening to events on previously set buffers", -> @@ -412,7 +412,7 @@ describe "Editor", -> oldBuffer = editor.getBuffer() editor.on 'editor-path-change', eventHandler - editor.edit(rootView.project.open(path)) + editor.edit(rootView.project.buildEditSessionForPath(path)) expect(eventHandler).toHaveBeenCalled() eventHandler.reset() @@ -1010,7 +1010,7 @@ describe "Editor", -> expect(editor.bufferPositionForScreenPosition(editor.getCursorScreenPosition())).toEqual [3, 60] it "does not wrap the lines of any newly assigned buffers", -> - otherEditSession = rootView.project.open() + otherEditSession = rootView.project.buildEditSessionForPath() otherEditSession.buffer.setText([1..100].join('')) editor.edit(otherEditSession) expect(editor.renderedLines.find('.line').length).toBe(1) @@ -1046,7 +1046,7 @@ describe "Editor", -> expect(editor.getCursorScreenPosition()).toEqual [11, 0] it "calls .setSoftWrapColumn() when the editor is attached because now its dimensions are available to calculate it", -> - otherEditor = new Editor(editSession: rootView.project.open('sample.js')) + otherEditor = new Editor(editSession: rootView.project.buildEditSessionForPath('sample.js')) spyOn(otherEditor, 'setSoftWrapColumn') otherEditor.setSoftWrap(true) @@ -1344,7 +1344,7 @@ describe "Editor", -> describe "when autoscrolling at the end of the document", -> it "renders lines properly", -> - editor.edit(rootView.project.open('two-hundred.txt')) + editor.edit(rootView.project.buildEditSessionForPath('two-hundred.txt')) editor.attachToDom(heightInLines: 5.5) expect(editor.renderedLines.find('.line').length).toBe 8 @@ -1510,7 +1510,7 @@ describe "Editor", -> describe "folding", -> beforeEach -> - editSession = rootView.project.open('two-hundred.txt') + editSession = rootView.project.buildEditSessionForPath('two-hundred.txt') buffer = editSession.buffer editor.edit(editSession) editor.attachToDom() @@ -1557,13 +1557,13 @@ describe "Editor", -> it "adds/removes the 'selected' class to the fold's line element and hides the cursor if it is on the fold line", -> editor.createFold(2, 4) - editor.setSelectedBufferRange([[1, 0], [2, 0]], reverse: true) + editor.setSelectedBufferRange([[1, 0], [2, 0]], preserveFolds: true, reverse: true) expect(editor.lineElementForScreenRow(2)).toMatchSelector('.fold.selected') - editor.setSelectedBufferRange([[1, 0], [1, 1]]) + editor.setSelectedBufferRange([[1, 0], [1, 1]], preserveFolds: true) expect(editor.lineElementForScreenRow(2)).not.toMatchSelector('.fold.selected') - editor.setSelectedBufferRange([[1, 0], [5, 0]]) + editor.setSelectedBufferRange([[1, 0], [5, 0]], preserveFolds: true) expect(editor.lineElementForScreenRow(2)).toMatchSelector('.fold.selected') editor.setCursorScreenPosition([3,0]) @@ -1582,7 +1582,7 @@ describe "Editor", -> editor.renderLines() # re-render lines so certain lines are not rendered editor.createFold(2, 4) - editor.setSelectedBufferRange([[1, 0], [5, 0]]) + editor.setSelectedBufferRange([[1, 0], [5, 0]], preserveFolds: true) expect(editor.renderedLines.find('.fold.selected')).toExist() editor.scrollToBottom() @@ -1593,8 +1593,8 @@ describe "Editor", -> describe ".getOpenBufferPaths()", -> it "returns the paths of all non-anonymous buffers with edit sessions on this editor", -> - editor.edit(project.open('sample.txt')) - editor.edit(project.open('two-hundred.txt')) - editor.edit(project.open()) + editor.edit(project.buildEditSessionForPath('sample.txt')) + editor.edit(project.buildEditSessionForPath('two-hundred.txt')) + editor.edit(project.buildEditSessionForPath()) paths = editor.getOpenBufferPaths().map (path) -> project.relativize(path) expect(paths).toEqual = ['sample.js', 'sample.txt', 'two-hundred.txt'] diff --git a/spec/app/project-spec.coffee b/spec/app/project-spec.coffee index 15ef5e7b5..bea202ef1 100644 --- a/spec/app/project-spec.coffee +++ b/spec/app/project-spec.coffee @@ -11,23 +11,19 @@ describe "Project", -> describe "when editSession is destroyed", -> it "removes edit session and calls destroy on buffer (if buffer is not referenced by other edit sessions)", -> - editSession = project.open("a") - anotherEditSession = project.open("a") - buffer = editSession.buffer - spyOn(buffer, 'destroy').andCallThrough() + editSession = project.buildEditSessionForPath("a") + anotherEditSession = project.buildEditSessionForPath("a") expect(project.editSessions.length).toBe 2 expect(editSession.buffer).toBe anotherEditSession.buffer editSession.destroy() - expect(buffer.destroy).not.toHaveBeenCalled() expect(project.editSessions.length).toBe 1 anotherEditSession.destroy() - expect(buffer.destroy).toHaveBeenCalled() expect(project.editSessions.length).toBe 0 - describe ".open(path)", -> + describe ".buildEditSessionForPath(path)", -> [absolutePath, newBufferHandler, newEditSessionHandler] = [] beforeEach -> absolutePath = require.resolve('fixtures/dir/a') @@ -38,34 +34,48 @@ describe "Project", -> describe "when given an absolute path that hasn't been opened previously", -> it "returns a new edit session for the given path and emits 'new-buffer' and 'new-edit-session' events", -> - editSession = project.open(absolutePath) + editSession = project.buildEditSessionForPath(absolutePath) expect(editSession.buffer.getPath()).toBe absolutePath expect(newBufferHandler).toHaveBeenCalledWith editSession.buffer expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when given a relative path that hasn't been opened previously", -> it "returns a new edit session for the given path (relative to the project root) and emits 'new-buffer' and 'new-edit-session' events", -> - editSession = project.open('a') + editSession = project.buildEditSessionForPath('a') expect(editSession.buffer.getPath()).toBe absolutePath expect(newBufferHandler).toHaveBeenCalledWith editSession.buffer expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when passed the path to a buffer that has already been opened", -> it "returns a new edit session containing previously opened buffer and emits a 'new-edit-session' event", -> - editSession = project.open(absolutePath) + editSession = project.buildEditSessionForPath(absolutePath) newBufferHandler.reset() - expect(project.open(absolutePath).buffer).toBe editSession.buffer - expect(project.open('a').buffer).toBe editSession.buffer + expect(project.buildEditSessionForPath(absolutePath).buffer).toBe editSession.buffer + expect(project.buildEditSessionForPath('a').buffer).toBe editSession.buffer expect(newBufferHandler).not.toHaveBeenCalled() expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when not passed a path", -> it "returns a new edit session and emits 'new-buffer' and 'new-edit-session' events", -> - editSession = project.open() + editSession = project.buildEditSessionForPath() expect(editSession.buffer.getPath()).toBeUndefined() expect(newBufferHandler).toHaveBeenCalledWith(editSession.buffer) expect(newEditSessionHandler).toHaveBeenCalledWith editSession + describe ".bufferForPath(path)", -> + describe "when opening a previously opened path", -> + it "does not create a new buffer", -> + buffer = project.bufferForPath("a").retain() + expect(project.bufferForPath("a")).toBe buffer + + alternativeBuffer = project.bufferForPath("b").retain().release() + expect(alternativeBuffer).not.toBe buffer + buffer.release() + + it "creates a new buffer if the previous buffer was destroyed", -> + buffer = project.bufferForPath("a").retain().release() + expect(project.bufferForPath("a").retain().release()).not.toBe buffer + describe ".resolve(path)", -> it "returns an absolute path based on the project's root", -> absolutePath = require.resolve('fixtures/dir/a') @@ -106,3 +116,60 @@ describe "Project", -> project.getFilePaths().done (paths) -> expect(paths).not.toContain('a') expect(paths).toContain('b') + + describe ".scan(options, callback)", -> + describe "when called with a regex", -> + it "calls the callback with all regex matches in all files in the project", -> + matches = [] + waitsForPromise -> + project.scan /(a)+/, ({path, match, range}) -> + matches.push({path, match, range}) + + runs -> + expect(matches[0]).toEqual + path: project.resolve('a') + match: 'aaa' + range: [[0, 0], [0, 3]] + + expect(matches[1]).toEqual + path: project.resolve('a') + match: 'aa' + range: [[1, 3], [1, 5]] + + it "works on evil filenames", -> + project.setPath(require.resolve('fixtures/evil-files')) + paths = [] + matches = [] + waitsForPromise -> + project.scan /evil/, ({path, match, range}) -> + paths.push(path) + matches.push(match) + + runs -> + expect(paths.length).toBe 5 + matches.forEach (match) -> expect(match).toEqual 'evil' + expect(paths[0]).toMatch /a_file_with_utf8.txt$/ + expect(paths[1]).toMatch /file with spaces.txt$/ + expect(paths[2]).toMatch /goddam\nnewlines$/m + expect(paths[3]).toMatch /quote".txt$/m + expect(fs.base(paths[4])).toBe "utfa\u0306.md" + + it "handles breaks in the search subprocess's output following the filename", -> + spyOn $native, 'exec' + + iterator = jasmine.createSpy('iterator') + project.scan /a+/, iterator + + stdout = $native.exec.argsForCall[0][1].stdout + stdout ":#{require.resolve('fixtures/dir/a')}\n" + stdout "1;0 3:aaa bbb\n2;3 2:cc aa cc\n" + + expect(iterator.argsForCall[0][0]).toEqual + path: project.resolve('a') + match: 'aaa' + range: [[0, 0], [0, 3]] + + expect(iterator.argsForCall[1][0]).toEqual + path: project.resolve('a') + match: 'aa' + range: [[1, 3], [1, 5]] diff --git a/spec/app/root-view-spec.coffee b/spec/app/root-view-spec.coffee index 5114e6569..ca5f85302 100644 --- a/spec/app/root-view-spec.coffee +++ b/spec/app/root-view-spec.coffee @@ -75,10 +75,10 @@ describe "RootView", -> editor2 = editor1.splitRight() editor3 = editor2.splitRight() editor4 = editor2.splitDown() - editor2.edit(rootView.project.open('dir/b')) - editor3.edit(rootView.project.open('sample.js')) + editor2.edit(rootView.project.buildEditSessionForPath('dir/b')) + editor3.edit(rootView.project.buildEditSessionForPath('sample.js')) editor3.setCursorScreenPosition([2, 3]) - editor4.edit(rootView.project.open('sample.txt')) + editor4.edit(rootView.project.buildEditSessionForPath('sample.txt')) editor4.setCursorScreenPosition([0, 2]) rootView.attachToDom() editor2.focus() @@ -455,7 +455,7 @@ describe "RootView", -> editor2 = rootView.getActiveEditor().splitLeft() path = rootView.project.resolve('b') - editor2.edit(rootView.project.open(path)) + editor2.edit(rootView.project.buildEditSessionForPath(path)) expect(pathChangeHandler).toHaveBeenCalled() expect(document.title).toBe rootView.project.resolve(path) @@ -491,7 +491,7 @@ describe "RootView", -> rootView.focus() expect(pathChangeHandler).not.toHaveBeenCalled() - editor2.edit(editor1.activeEditSession) + editor2.edit(editor1.activeEditSession.copy()) editor2.focus() expect(pathChangeHandler).not.toHaveBeenCalled() @@ -526,16 +526,18 @@ describe "RootView", -> expect(rootView.getActiveEditor()).toBeUndefined() describe "when called with no path", -> - it "opens an empty buffer in a new editor", -> - rootView.open() + it "opens / returns an edit session for an empty buffer in a new editor", -> + editSession = rootView.open() expect(rootView.getActiveEditor()).toBeDefined() expect(rootView.getActiveEditor().getPath()).toBeUndefined() + expect(editSession).toBe rootView.getActiveEditor().activeEditSession describe "when called with a path", -> it "opens a buffer with the given path in a new editor", -> - rootView.open('b') + editSession = rootView.open('b') expect(rootView.getActiveEditor()).toBeDefined() expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/dir/b') + expect(editSession).toBe rootView.getActiveEditor().activeEditSession describe "when there is an active editor", -> beforeEach -> @@ -543,8 +545,9 @@ describe "RootView", -> describe "when called with no path", -> it "opens an empty buffer in the active editor", -> - rootView.open() + editSession = rootView.open() expect(rootView.getActiveEditor().getPath()).toBeUndefined() + expect(editSession).toBe rootView.getActiveEditor().activeEditSession describe "when called with a path", -> [editor1, editor2] = [] @@ -566,16 +569,19 @@ describe "RootView", -> expect(activeEditor.getPath()).toBe require.resolve('fixtures/dir/a') previousEditSession = activeEditor.activeEditSession - rootView.open('b') + editSession = rootView.open('b') expect(activeEditor.activeEditSession).not.toBe previousEditSession + expect(editSession).toBe rootView.getActiveEditor().activeEditSession - rootView.open('a') + editSession = rootView.open('a') expect(activeEditor.activeEditSession).toBe previousEditSession + expect(editSession).toBe previousEditSession describe "when the active editor does not have an edit session for the given path", -> it "creates a new edit session for the given path in the active editor", -> - rootView.open('b') + editSession = rootView.open('b') expect(activeEditor.editSessions.length).toBe 2 + expect(editSession).toBe rootView.getActiveEditor().activeEditSession describe "when the 'allowActiveEditorChange' option is true", -> describe "when the active editor has an edit session for the given path", -> @@ -584,23 +590,27 @@ describe "RootView", -> expect(activeEditor.getPath()).toBe require.resolve('fixtures/dir/a') previousEditSession = activeEditor.activeEditSession - rootView.open('b') + editSession = rootView.open('b') expect(activeEditor.activeEditSession).not.toBe previousEditSession + expect(editSession).toBe activeEditor.activeEditSession - rootView.open('a', allowActiveEditorChange: true) + editSession = rootView.open('a', allowActiveEditorChange: true) expect(activeEditor.activeEditSession).toBe previousEditSession + expect(editSession).toBe activeEditor.activeEditSession describe "when the active editor does *not* have an edit session for the given path", -> describe "when another editor has an edit session for the path", -> it "focuses the other editor and activates its edit session for the path", -> expect(rootView.getActiveEditor()).toBe editor1 - rootView.open('b', allowActiveEditorChange: true) + editSession = rootView.open('b', allowActiveEditorChange: true) expect(rootView.getActiveEditor()).toBe editor2 expect(editor2.getPath()).toBe require.resolve('fixtures/dir/b') + expect(editSession).toBe rootView.getActiveEditor().activeEditSession describe "when no other editor has an edit session for the path either", -> it "creates a new edit session for the path on the current active editor", -> path = require.resolve('fixtures/sample.js') - rootView.open(path, allowActiveEditorChange: true) + editSession = rootView.open(path, allowActiveEditorChange: true) expect(rootView.getActiveEditor()).toBe editor1 expect(editor1.getPath()).toBe path + expect(editSession).toBe rootView.getActiveEditor().activeEditSession diff --git a/spec/app/selection-spec.coffee b/spec/app/selection-spec.coffee index 0a91272ea..a43ad7f1e 100644 --- a/spec/app/selection-spec.coffee +++ b/spec/app/selection-spec.coffee @@ -66,3 +66,12 @@ describe "Selection", -> buffer.insert([2, 5], 'abc') expect(changeScreenRangeHandler).toHaveBeenCalled() + + describe "when the selection is destroyed", -> + it "destroys its cursor and its anchor's cursor", -> + selection.setBufferRange([[2, 0], [2, 10]]) + + selection.destroy() + + expect(editSession.getAnchors().indexOf(selection.anchor)).toBe -1 + expect(editSession.getAnchors().indexOf(selection.cursor.anchor)).toBe -1 diff --git a/spec/extensions/autocomplete-spec.coffee b/spec/extensions/autocomplete-spec.coffee index ca430de4c..442390f47 100644 --- a/spec/extensions/autocomplete-spec.coffee +++ b/spec/extensions/autocomplete-spec.coffee @@ -10,12 +10,12 @@ describe "Autocomplete", -> miniEditor = null beforeEach -> - editor = new Editor(editSession: fixturesProject.open('sample.js')) + editor = new Editor(editSession: fixturesProject.buildEditSessionForPath('sample.js')) autocomplete = new Autocomplete(editor) miniEditor = autocomplete.miniEditor afterEach -> - editor.remove() + editor?.remove() describe "@activate(rootView)", -> it "activates autocomplete on all existing and future editors (but not on autocomplete's own mini editor)", -> @@ -38,8 +38,7 @@ describe "Autocomplete", -> expect(Autocomplete.prototype.initialize).not.toHaveBeenCalled() - leftEditor.remove() - rightEditor.remove() + rootView.deactivate() describe 'autocomplete:attach event', -> it "shows autocomplete view and focuses its mini-editor", -> @@ -357,7 +356,7 @@ describe "Autocomplete", -> expect(wordList).toContain "quicksort" expect(wordList).not.toContain "Some" - editor.edit(fixturesProject.open('sample.txt')) + editor.edit(fixturesProject.buildEditSessionForPath('sample.txt')) wordList = autocomplete.wordList expect(wordList).not.toContain "quicksort" @@ -365,7 +364,7 @@ describe "Autocomplete", -> it 'stops listening to previous buffers change events', -> previousBuffer = editor.getBuffer() - editor.edit(fixturesProject.open('sample.txt')) + editor.edit(fixturesProject.buildEditSessionForPath('sample.txt')) spyOn(autocomplete, "buildWordList") previousBuffer.change([[0,0],[0,1]], "sauron") @@ -382,6 +381,7 @@ describe "Autocomplete", -> editor.remove() editor.getBuffer().insert([0,0], "s") expect(autocomplete.buildWordList).not.toHaveBeenCalled() + editor = null describe ".attach()", -> beforeEach -> diff --git a/spec/extensions/command-interpreter-spec.coffee b/spec/extensions/command-interpreter-spec.coffee index b4c7c1e46..143f64754 100644 --- a/spec/extensions/command-interpreter-spec.coffee +++ b/spec/extensions/command-interpreter-spec.coffee @@ -1,258 +1,329 @@ CommandInterpreter = require 'command-panel/command-interpreter' +Project = require 'project' Buffer = require 'buffer' EditSession = require 'edit-session' -Editor = require 'editor' describe "CommandInterpreter", -> - [interpreter, editor, buffer] = [] + [project, interpreter, editSession, buffer, anchorCountBefore] = [] beforeEach -> - editSession = fixturesProject.open('sample.js') + project = new Project(fixturesProject.resolve('dir/')) + interpreter = new CommandInterpreter(fixturesProject) + editSession = fixturesProject.buildEditSessionForPath('sample.js') buffer = editSession.buffer - editor = new Editor(editSession: editSession) - interpreter = new CommandInterpreter() afterEach -> - editor.remove() + editSession?.destroy() + expect(buffer.getAnchors().length).toBe 0 describe "addresses", -> beforeEach -> - editor.addSelectionForBufferRange([[7,0], [7,11]]) - editor.addSelectionForBufferRange([[8,0], [8,11]]) + editSession.addSelectionForBufferRange([[7,0], [7,11]]) + editSession.addSelectionForBufferRange([[8,0], [8,11]]) describe "a line address", -> it "selects the specified line", -> - interpreter.eval(editor, '4') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[3, 0], [4, 0]] + waitsForPromise -> interpreter.eval('4', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[3, 0], [4, 0]] describe "0", -> it "selects the zero-length string at the start of the file", -> - interpreter.eval(editor, '0') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[0,0], [0,0]] + waitsForPromise -> interpreter.eval('0', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[0,0], [0,0]] - interpreter.eval(editor, '0,1') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[0,0], [1,0]] + interpreter.eval('0,1', editSession) + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[0,0], [1,0]] describe "$", -> it "selects EOF", -> - interpreter.eval(editor, '$') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[12,2], [12,2]] + waitsForPromise -> interpreter.eval('$', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[12,2], [12,2]] - interpreter.eval(editor, '1,$') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[0,0], [12,2]] + waitsForPromise -> interpreter.eval('1,$', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[0,0], [12,2]] describe ".", -> describe "when a single selection", -> it 'maintains the current selection', -> - editor.clearSelections() - editor.setSelectedBufferRange([[1,1], [2,2]]) - interpreter.eval(editor, '.') - expect(editor.getSelection().getBufferRange()).toEqual [[1,1], [2,2]] + editSession.clearSelections() - editor.setSelectedBufferRange([[1,1], [2,2]]) - interpreter.eval(editor, '.,') - expect(editor.getSelection().getBufferRange()).toEqual [[1,1], [12,2]] + waitsForPromise -> + editSession.setSelectedBufferRange([[1,1], [2,2]]) + interpreter.eval('.', editSession) - editor.setSelectedBufferRange([[1,1], [2,2]]) - interpreter.eval(editor, ',.') - expect(editor.getSelection().getBufferRange()).toEqual [[0,0], [2,2]] + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[1,1], [2,2]] + + waitsForPromise -> + editSession.setSelectedBufferRange([[1,1], [2,2]]) + interpreter.eval('.,', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[1,1], [12,2]] + + waitsForPromise -> + editSession.setSelectedBufferRange([[1,1], [2,2]]) + interpreter.eval(',.', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[0,0], [2,2]] describe "with multiple selections", -> it "maintains the current selections", -> - preSelections = editor.getSelections() + preSelections = editSession.getSelections() expect(preSelections.length).toBe 3 [preRange1, preRange2, preRange3] = preSelections.map (s) -> s.getScreenRange() - interpreter.eval(editor, '.') + waitsForPromise -> interpreter.eval('.', editSession) - selections = editor.getSelections() - expect(selections.length).toBe 3 - [selection1, selection2, selection3] = selections - expect(selection1.getScreenRange()).toEqual preRange1 - expect(selection2.getScreenRange()).toEqual preRange2 - expect(selection3.getScreenRange()).toEqual preRange3 + runs -> + selections = editSession.getSelections() + expect(selections.length).toBe 3 + [selection1, selection2, selection3] = selections + expect(selection1.getScreenRange()).toEqual preRange1 + expect(selection2.getScreenRange()).toEqual preRange2 + expect(selection3.getScreenRange()).toEqual preRange3 describe "/regex/", -> beforeEach -> - editor.clearSelections() + editSession.clearSelections() it 'selects text matching regex after current selection', -> - editor.setSelectedBufferRange([[4,16], [4,20]]) - interpreter.eval(editor, '/pivot/') - expect(editor.getSelection().getBufferRange()).toEqual [[6,16], [6,21]] + waitsForPromise -> + editSession.setSelectedBufferRange([[4,16], [4,20]]) + interpreter.eval('/pivot/', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[6,16], [6,21]] it 'does not require the trailing slash', -> - editor.setSelectedBufferRange([[4,16], [4,20]]) - interpreter.eval(editor, '/pivot') - expect(editor.getSelection().getBufferRange()).toEqual [[6,16], [6,21]] + waitsForPromise -> + editSession.setSelectedBufferRange([[4,16], [4,20]]) + interpreter.eval('/pivot', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[6,16], [6,21]] it "searches from the end of each selection in the buffer", -> - editor.clearSelections() - editor.setSelectedBufferRange([[4,16], [4,20]]) - editor.addSelectionForBufferRange([[1,16], [2,20]]) - expect(editor.getSelections().length).toBe 2 - interpreter.eval(editor, '/pivot') - selections = editor.getSelections() - expect(selections.length).toBe 2 - expect(selections[0].getBufferRange()).toEqual [[3,8], [3,13]] - expect(selections[1].getBufferRange()).toEqual [[6,16], [6,21]] + waitsForPromise -> + editSession.clearSelections() + editSession.setSelectedBufferRange([[4,16], [4,20]]) + editSession.addSelectionForBufferRange([[1,16], [2,20]]) + expect(editSession.getSelections().length).toBe 2 + interpreter.eval('/pivot', editSession) + + runs -> + selections = editSession.getSelections() + expect(selections.length).toBe 2 + expect(selections[0].getBufferRange()).toEqual [[3,8], [3,13]] + expect(selections[1].getBufferRange()).toEqual [[6,16], [6,21]] it "wraps around to the beginning of the buffer, but doesn't infinitely loop if no matches are found", -> - editor.setSelectedBufferRange([[10, 0], [10,3]]) - interpreter.eval(editor, '/pivot') - expect(editor.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] + waitsForPromise -> + editSession.setSelectedBufferRange([[10, 0], [10,3]]) + interpreter.eval('/pivot', editSession) - interpreter.eval(editor, '/mike tyson') - expect(editor.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] + + waitsForPromise -> + interpreter.eval('/mike tyson', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] it "searches in reverse when prefixed with a -", -> - editor.setSelectedBufferRange([[6, 16], [6, 22]]) - interpreter.eval(editor, '-/pivot') - expect(editor.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] + waitsForPromise -> + editSession.setSelectedBufferRange([[6, 16], [6, 22]]) + interpreter.eval('-/pivot', editSession) + + runs -> + expect(editSession.getSelection().getBufferRange()).toEqual [[3,8], [3,13]] describe "address range", -> describe "when two addresses are specified", -> it "selects from the begining of the left address to the end of the right address", -> - interpreter.eval(editor, '4,7') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[3, 0], [7, 0]] + waitsForPromise -> interpreter.eval('4,7', editSession) + + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[3, 0], [7, 0]] describe "when the left address is unspecified", -> it "selects from the begining of buffer to the end of the right address", -> - interpreter.eval(editor, ',7') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[0, 0], [7, 0]] + waitsForPromise -> interpreter.eval(',7', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[0, 0], [7, 0]] describe "when the right address is unspecified", -> it "selects from the begining of left address to the end file", -> - interpreter.eval(editor, '4,') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[3, 0], [12, 2]] + waitsForPromise -> interpreter.eval('4,', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[3, 0], [12, 2]] describe "when the neither address is specified", -> it "selects the entire file", -> - interpreter.eval(editor, ',') - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelection().getBufferRange()).toEqual [[0, 0], [12, 2]] + waitsForPromise -> interpreter.eval(',', editSession) + runs -> + expect(editSession.getSelections().length).toBe 1 + expect(editSession.getSelection().getBufferRange()).toEqual [[0, 0], [12, 2]] describe "x/regex/", -> it "sets the current selection to every match of the regex in the current selection", -> - interpreter.eval(editor, '6,7 x/current/') + waitsForPromise -> interpreter.eval('6,7 x/current/', editSession) - selections = editor.getSelections() - expect(selections.length).toBe 4 + runs -> + selections = editSession.getSelections() + expect(selections.length).toBe 4 - expect(selections[0].getBufferRange()).toEqual [[5,6], [5,13]] - expect(selections[1].getBufferRange()).toEqual [[6,6], [6,13]] - expect(selections[2].getBufferRange()).toEqual [[6,34], [6,41]] - expect(selections[3].getBufferRange()).toEqual [[6,56], [6,63]] + expect(selections[0].getBufferRange()).toEqual [[5,6], [5,13]] + expect(selections[1].getBufferRange()).toEqual [[6,6], [6,13]] + expect(selections[2].getBufferRange()).toEqual [[6,34], [6,41]] + expect(selections[3].getBufferRange()).toEqual [[6,56], [6,63]] describe "when matching /$/", -> it "matches the end of each line in the selected region", -> - interpreter.eval(editor, '6,8 x/$/') + waitsForPromise -> interpreter.eval('6,8 x/$/', editSession) - cursors = editor.getCursors() - expect(cursors.length).toBe 3 + runs -> + cursors = editSession.getCursors() + expect(cursors.length).toBe 3 - expect(cursors[0].getBufferPosition()).toEqual [5, 30] - expect(cursors[1].getBufferPosition()).toEqual [6, 65] - expect(cursors[2].getBufferPosition()).toEqual [7, 5] + expect(cursors[0].getBufferPosition()).toEqual [5, 30] + expect(cursors[1].getBufferPosition()).toEqual [6, 65] + expect(cursors[2].getBufferPosition()).toEqual [7, 5] - it "loops through current selections and selects text matching the regex", -> - editor.setSelectedBufferRange [[3,0], [3,62]] - editor.addSelectionForBufferRange [[6,0], [6,65]] + describe "when text is initially selected", -> + it "loops through current selections and selects text matching the regex", -> + waitsForPromise -> + editSession.setSelectedBufferRange [[3,0], [3,62]] + editSession.addSelectionForBufferRange [[6,0], [6,65]] + interpreter.eval('x/current', editSession) - interpreter.eval(editor, 'x/current') + runs -> + selections = editSession.getSelections() + expect(selections.length).toBe 4 - selections = editor.getSelections() - expect(selections.length).toBe 4 - - expect(selections[0].getBufferRange()).toEqual [[3,31], [3,38]] - expect(selections[1].getBufferRange()).toEqual [[6,6], [6,13]] - expect(selections[2].getBufferRange()).toEqual [[6,34], [6,41]] - expect(selections[3].getBufferRange()).toEqual [[6,56], [6,63]] + expect(selections[0].getBufferRange()).toEqual [[3,31], [3,38]] + expect(selections[1].getBufferRange()).toEqual [[6,6], [6,13]] + expect(selections[2].getBufferRange()).toEqual [[6,34], [6,41]] + expect(selections[3].getBufferRange()).toEqual [[6,56], [6,63]] describe "substitution", -> it "does nothing if there are no matches", -> - editor.setSelectedBufferRange([[6, 0], [6, 44]]) - interpreter.eval(editor, 's/not-in-text/foo/') - expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);' + waitsForPromise -> + editSession.setSelectedBufferRange([[6, 0], [6, 44]]) + interpreter.eval('s/not-in-text/foo/', editSession) + + runs -> + expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);' describe "when not global", -> describe "when there is a single selection", -> it "performs a single substitution within the current selection", -> - editor.setSelectedBufferRange([[6, 0], [6, 44]]) - interpreter.eval(editor, 's/current/foo/') - expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(current) : right.push(current);' + waitsForPromise -> + editSession.setSelectedBufferRange([[6, 0], [6, 44]]) + interpreter.eval('s/current/foo/', editSession) + runs -> + expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(current) : right.push(current);' describe "when there are multiple selections", -> it "performs a single substitutions within each of the selections", -> - editor.setSelectedBufferRange([[5, 0], [5, 20]]) - editor.addSelectionForBufferRange([[6, 0], [6, 44]]) + waitsForPromise -> + editSession.setSelectedBufferRange([[5, 0], [5, 20]]) + editSession.addSelectionForBufferRange([[6, 0], [6, 44]]) + interpreter.eval('s/current/foo/', editSession) - interpreter.eval(editor, 's/current/foo/') - expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' - expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(current) : right.push(current);' + runs -> + expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' + expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(current) : right.push(current);' describe "when global", -> it "performs a multiple substitutions within the current selection", -> - editor.setSelectedBufferRange([[6, 0], [6, 44]]) - interpreter.eval(editor, 's/current/foo/g') - expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(foo) : right.push(current);' + waitsForPromise -> + editSession.setSelectedBufferRange([[6, 0], [6, 44]]) + interpreter.eval('s/current/foo/g', editSession) - describe "when prefixed with an address", -> - it "only makes substitutions within given lines", -> - interpreter.eval(editor, '4,6s/ /!/g') - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' - expect(buffer.lineForRow(3)).toBe '!!!!var!pivot!=!items.shift(),!current,!left!=![],!right!=![];' - expect(buffer.lineForRow(4)).toBe '!!!!while(items.length!>!0)!{' - expect(buffer.lineForRow(5)).toBe '!!!!!!current!=!items.shift();' - expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);' - - describe "when matching $", -> - it "matches the end of each line and avoids infinitely looping on a zero-width match", -> - interpreter.eval(editor, ',s/$/!!!/g') - expect(buffer.lineForRow(0)).toBe 'var quicksort = function () {!!!' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;!!!' - expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);!!!' - expect(buffer.lineForRow(12)).toBe '};!!!' - - describe "when matching ^", -> - it "matches the beginning of each line and avoids infinitely looping on a zero-width match", -> - interpreter.eval(editor, ',s/^/!!!/g') - expect(buffer.lineForRow(0)).toBe '!!!var quicksort = function () {' - expect(buffer.lineForRow(2)).toBe '!!! if (items.length <= 1) return items;' - expect(buffer.lineForRow(6)).toBe '!!! current < pivot ? left.push(current) : right.push(current);' - expect(buffer.lineForRow(12)).toBe '!!!};' - - describe "when there are multiple selections", -> - it "performs a multiple substitutions within each of the selections", -> - editor.setSelectedBufferRange([[5, 0], [5, 20]]) - editor.addSelectionForBufferRange([[6, 0], [6, 44]]) - - interpreter.eval(editor, 's/current/foo/g') - expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' + runs -> expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(foo) : right.push(current);' + describe "when prefixed with an address", -> + it "only makes substitutions within given lines", -> + waitsForPromise -> interpreter.eval('4,6s/ /!/g', editSession) + + runs -> + expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' + expect(buffer.lineForRow(3)).toBe '!!!!var!pivot!=!items.shift(),!current,!left!=![],!right!=![];' + expect(buffer.lineForRow(4)).toBe '!!!!while(items.length!>!0)!{' + expect(buffer.lineForRow(5)).toBe '!!!!!!current!=!items.shift();' + expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);' + + describe "when matching $", -> + it "matches the end of each line and avoids infinitely looping on a zero-width match", -> + waitsForPromise -> interpreter.eval(',s/$/!!!/g', editSession) + runs -> + expect(buffer.lineForRow(0)).toBe 'var quicksort = function () {!!!' + expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;!!!' + expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(current) : right.push(current);!!!' + expect(buffer.lineForRow(12)).toBe '};!!!' + + describe "when matching ^", -> + it "matches the beginning of each line and avoids infinitely looping on a zero-width match", -> + waitsForPromise -> interpreter.eval(',s/^/!!!/g', editSession) + runs -> + expect(buffer.lineForRow(0)).toBe '!!!var quicksort = function () {' + expect(buffer.lineForRow(2)).toBe '!!! if (items.length <= 1) return items;' + expect(buffer.lineForRow(6)).toBe '!!! current < pivot ? left.push(current) : right.push(current);' + expect(buffer.lineForRow(12)).toBe '!!!};' + + describe "when there are multiple selections", -> + it "performs a multiple substitutions within each of the selections", -> + waitsForPromise -> + editSession.setSelectedBufferRange([[5, 0], [5, 20]]) + editSession.addSelectionForBufferRange([[6, 0], [6, 44]]) + interpreter.eval('s/current/foo/g', editSession) + + runs -> + expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' + expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(foo) : right.push(current);' + describe "when prefixed with an address", -> it "restores the original selections upon completion if it is the last command", -> - editor.setSelectedBufferRanges([[[5, 0], [5, 20]], [[6, 0], [6, 44]]]) - interpreter.eval(editor, ',s/current/foo/g') - expect(editor.getSelectedBufferRanges()).toEqual [[[5, 0], [5, 20]], [[6, 0], [6, 44]]] + waitsForPromise -> + editSession.setSelectedBufferRanges([[[5, 0], [5, 20]], [[6, 0], [6, 44]]]) + interpreter.eval(',s/current/foo/g', editSession) - describe "when command selects folded text", -> - it "unfolds lines that command selects", -> - editor.createFold(1, 9) - editor.createFold(5, 8) - editor.setSelectedBufferRange([[0,0], [0,0]]) + runs -> + expect(editSession.getSelectedBufferRanges()).toEqual [[[5, 0], [5, 16]], [[6, 0], [6, 36]]] - interpreter.eval(editor, '/push/') - expect(editor.getSelection().getBufferRange()).toEqual [[6,29], [6,33]] - expect(editor.lineForScreenRow(1).fold).toBeUndefined() - expect(editor.lineForScreenRow(5).fold).toBeUndefined() - expect(editor.lineForScreenRow(6).text).toBe buffer.lineForRow(6) + describe "X x/regex/", -> + it "returns selection operations for all regex matches in all the project's files", -> + editSession.destroy() + project = new Project(fixturesProject.resolve('dir/')) + interpreter = new CommandInterpreter(project) + + operations = null + waitsForPromise -> + interpreter.eval("X x/a+/").done (ops) -> operations = ops + + runs -> + expect(operations.length).toBeGreaterThan 3 + for operation in operations + editSession = project.buildEditSessionForPath(operation.getPath()) + operation.execute(editSession) + expect(editSession.getSelectedText()).toMatch /a+/ + editSession.destroy() + operation.destroy() + + editSession = null diff --git a/spec/extensions/command-panel-spec.coffee b/spec/extensions/command-panel-spec.coffee index f28fd4e63..9fe0e52f2 100644 --- a/spec/extensions/command-panel-spec.coffee +++ b/spec/extensions/command-panel-spec.coffee @@ -1,18 +1,21 @@ RootView = require 'root-view' CommandPanel = require 'command-panel' +_ = require 'underscore' describe "CommandPanel", -> - [rootView, editor, commandPanel] = [] + [rootView, editor, buffer, commandPanel, project] = [] beforeEach -> rootView = new RootView rootView.open(require.resolve 'fixtures/sample.js') rootView.enableKeymap() + project = rootView.project editor = rootView.getActiveEditor() + buffer = editor.activeEditSession.buffer commandPanel = requireExtension('command-panel') afterEach -> - rootView.remove() + rootView.deactivate() describe "serialization", -> it "preserves the command panel's mini editor text and visibility across reloads", -> @@ -26,6 +29,12 @@ describe "CommandPanel", -> newRootView.remove() + describe "when command-panel:close is triggered on the command panel", -> + it "detaches the command panel", -> + commandPanel.attach() + commandPanel.trigger('command-panel:close') + expect(commandPanel.hasParent()).toBeFalsy() + describe "when command-panel:toggle is triggered on the root view", -> beforeEach -> rootView.attachToDom() @@ -34,14 +43,14 @@ describe "CommandPanel", -> beforeEach -> commandPanel.attach() - describe "when the command panel is focused", -> + describe "when the mini editor is focused", -> it "closes the command panel", -> expect(commandPanel.miniEditor.hiddenInput).toMatchSelector ':focus' rootView.trigger 'command-panel:toggle' expect(commandPanel.hasParent()).toBeFalsy() - describe "when the command panel is not focused", -> - it "focuses the command panel", -> + describe "when the mini editor is not focused", -> + it "focuses the mini editor", -> rootView.focus() expect(commandPanel.miniEditor.hiddenInput).not.toMatchSelector ':focus' rootView.trigger 'command-panel:toggle' @@ -54,6 +63,82 @@ describe "CommandPanel", -> rootView.trigger 'command-panel:toggle' expect(commandPanel.hasParent()).toBeTruthy() + describe "when command-panel:toggle-preview is triggered on the root view", -> + beforeEach -> + rootView.attachToDom() + + describe "when the preview list is/was previously visible", -> + beforeEach -> + rootView.trigger 'command-panel:toggle' + waitsForPromise -> commandPanel.execute('X x/a+/') + + describe "when the command panel is visible", -> + beforeEach -> + expect(commandPanel.hasParent()).toBeTruthy() + + describe "when the preview list is visible", -> + beforeEach -> + expect(commandPanel.previewList).toBeVisible() + + describe "when the preview list is focused", -> + it "hides the command panel", -> + expect(commandPanel.previewList).toMatchSelector(':focus') + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.hasParent()).toBeFalsy() + + describe "when the preview list is not focused", -> + it "focuses the preview list", -> + commandPanel.miniEditor.focus() + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.previewList).toMatchSelector(':focus') + + describe "when the preview list is not visible", -> + beforeEach -> + commandPanel.miniEditor.focus() + rootView.trigger 'command-panel:toggle' + rootView.trigger 'command-panel:toggle' + expect(commandPanel.hasParent()).toBeTruthy() + expect(commandPanel.previewList).toBeHidden() + + it "shows and focuses the preview list", -> + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.previewList).toBeVisible() + expect(commandPanel.previewList).toMatchSelector(':focus') + + describe "when the command panel is not visible", -> + it "shows the command panel and the preview list, and focuses the preview list", -> + commandPanel.miniEditor.focus() + rootView.trigger 'command-panel:toggle' + expect(commandPanel.hasParent()).toBeFalsy() + + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.hasParent()).toBeTruthy() + expect(commandPanel.previewList).toBeVisible() + expect(commandPanel.previewList).toMatchSelector(':focus') + + describe "when the preview list has never been opened", -> + describe "when the command panel is visible", -> + beforeEach -> + rootView.trigger 'command-panel:toggle' + expect(commandPanel.hasParent()).toBeTruthy() + + describe "when the mini editor is focused", -> + it "retains focus on the mini editor and does not show the preview list", -> + expect(commandPanel.miniEditor.isFocused).toBeTruthy() + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.previewList).toBeHidden() + expect(commandPanel.miniEditor.isFocused).toBeTruthy() + + describe "when the mini editor is not focused", -> + it "focuses the mini editor and does not show the preview list", -> + rootView.focus() + rootView.trigger 'command-panel:toggle-preview' + expect(commandPanel.previewList).toBeHidden() + expect(commandPanel.miniEditor.isFocused).toBeTruthy() + + describe "when the command panel is not visible", -> + it "shows the command panel and focuses the mini editor, but does not show the preview list", -> + describe "when command-panel:unfocus is triggered on the command panel", -> it "returns focus to the root view but does not hide the command panel", -> rootView.attachToDom() @@ -63,7 +148,6 @@ describe "CommandPanel", -> expect(commandPanel.hasParent()).toBeTruthy() expect(commandPanel.miniEditor.hiddenInput).not.toMatchSelector ':focus' - describe "when command-panel:repeat-relative-address is triggered on the root view", -> it "repeats the last search command if there is one", -> rootView.trigger 'command-panel:repeat-relative-address' @@ -111,19 +195,61 @@ describe "CommandPanel", -> expect(commandInterpreter.lastRelativeAddress.subcommands[0].regex.toString()).toEqual "/\\(items\\)/" describe "when command-panel:find-in-file is triggered on an editor", -> - it "pre-populates command panel's editor with /", -> + it "pre-populates the command panel's editor with / and moves the cursor to the last column", -> + spyOn(commandPanel, 'attach').andCallThrough() + commandPanel.miniEditor.setText("foo") + commandPanel.miniEditor.setCursorBufferPosition([0, 0]) + rootView.getActiveEditor().trigger "command-panel:find-in-file" + expect(commandPanel.attach).toHaveBeenCalled() expect(commandPanel.parent).not.toBeEmpty() expect(commandPanel.miniEditor.getText()).toBe "/" + expect(commandPanel.miniEditor.getCursorBufferPosition()).toEqual [0, 1] + + describe "when command-panel:find-in-project is triggered on the root view", -> + it "pre-populates the command panel's editor with Xx/ and moves the cursor to the last column", -> + spyOn(commandPanel, 'attach').andCallThrough() + commandPanel.miniEditor.setText("foo") + commandPanel.miniEditor.setCursorBufferPosition([0, 0]) + + rootView.trigger "command-panel:find-in-project" + expect(commandPanel.attach).toHaveBeenCalled() + expect(commandPanel.parent).not.toBeEmpty() + expect(commandPanel.miniEditor.getText()).toBe "Xx/" + expect(commandPanel.miniEditor.getCursorBufferPosition()).toEqual [0, 3] describe "when return is pressed on the panel's editor", -> - it "calls execute", -> - spyOn(commandPanel, 'execute') - rootView.trigger 'command-panel:toggle' - commandPanel.miniEditor.insertText 's/hate/love/g' - commandPanel.miniEditor.hiddenInput.trigger keydownEvent('enter') + describe "if the command has an immediate effect", -> + it "executes it immediately on the current buffer", -> + rootView.trigger 'command-panel:toggle' + commandPanel.miniEditor.insertText ',s/sort/torta/g' + commandPanel.miniEditor.hiddenInput.trigger keydownEvent('enter') - expect(commandPanel.execute).toHaveBeenCalled() + expect(buffer.lineForRow(0)).toMatch /quicktorta/ + expect(buffer.lineForRow(1)).toMatch /var torta/ + + describe "when the command returns operations to be previewed", -> + beforeEach -> + rootView.attachToDom() + editor.remove() + rootView.trigger 'command-panel:toggle' + waitsForPromise -> commandPanel.execute('X x/a+/') + + it "displays and focuses the operation preview list", -> + expect(commandPanel).toBeVisible() + expect(commandPanel.previewList).toBeVisible() + expect(commandPanel.previewList).toMatchSelector ':focus' + previewItem = commandPanel.previewList.find("li:contains(dir/a):first") + expect(previewItem.find('.path').text()).toBe "dir/a" + expect(previewItem.find('.preview').text()).toBe "aaa bbb" + expect(previewItem.find('.preview > .match').text()).toBe "aaa" + + rootView.trigger 'command-panel:toggle-preview' # ensure we can close panel without problems + expect(commandPanel).toBeHidden() + + it "destroys previously previewed operations if there are any", -> + waitsForPromise -> commandPanel.execute('X x/b+/') + # there shouldn't be any dangling operations after this describe "if the command is malformed", -> it "adds and removes an error class to the command panel and does not close it", -> @@ -156,12 +282,74 @@ describe "CommandPanel", -> commandPanel.miniEditor.trigger 'move-down' expect(commandPanel.miniEditor.getText()).toBe '' - describe ".execute()", -> - it "executes the command and closes the command panel", -> - rootView.getActiveEditor().setText("i hate love") - rootView.getActiveEditor().getSelection().setBufferRange [[0,0], [0,Infinity]] + describe "when the preview list is focused with search operations", -> + previewList = null + + beforeEach -> + previewList = commandPanel.previewList rootView.trigger 'command-panel:toggle' - commandPanel.miniEditor.insertText 's/hate/love/' - commandPanel.execute() - expect(rootView.getActiveEditor().getText()).toBe "i love love" - expect(rootView.find('.command-panel')).not.toExist() + waitsForPromise -> commandPanel.execute('X x/a+/') + + describe "when move-down and move-up are triggered on the preview list", -> + it "selects the next/previous operation (if there is one), and scrolls the list if needed", -> + rootView.attachToDom() + expect(previewList.find('li:eq(0)')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe previewList.getOperations()[0] + + previewList.trigger 'move-up' + expect(previewList.find('li:eq(0)')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe previewList.getOperations()[0] + + previewList.trigger 'move-down' + expect(previewList.find('li:eq(1)')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe previewList.getOperations()[1] + + previewList.trigger 'move-down' + expect(previewList.find('li:eq(2)')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe previewList.getOperations()[2] + + previewList.trigger 'move-up' + expect(previewList.find('li:eq(1)')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe previewList.getOperations()[1] + + _.times previewList.getOperations().length, -> previewList.trigger 'move-down' + + expect(previewList.find('li:last')).toHaveClass 'selected' + expect(previewList.getSelectedOperation()).toBe _.last(previewList.getOperations()) + + expect(previewList.scrollBottom()).toBe previewList.prop('scrollHeight') + + _.times previewList.getOperations().length, -> previewList.trigger 'move-up' + + console.log previewList.find('li:first').position().top + + describe "when command-panel:execute is triggered on the preview list", -> + it "opens the operation's buffer, selects the search result, and focuses the active editor", -> + spyOn(rootView, 'focus') + executeHandler = jasmine.createSpy('executeHandler') + commandPanel.on 'command-panel:execute', executeHandler + + _.times 4, -> previewList.trigger 'move-down' + operation = previewList.getSelectedOperation() + + previewList.trigger 'command-panel:execute' + + editSession = rootView.getActiveEditSession() + expect(editSession.buffer.getPath()).toBe project.resolve(operation.getPath()) + expect(editSession.getSelectedBufferRange()).toEqual operation.getBufferRange() + expect(rootView.focus).toHaveBeenCalled() + + expect(executeHandler).not.toHaveBeenCalled() + + describe "when an operation in the preview list is clicked", -> + it "opens the operation's buffer, selects the search result, and focuses the active editor", -> + spyOn(rootView, 'focus') + operation = previewList.getOperations()[4] + + previewList.find('li:eq(4) span').mousedown() + + expect(previewList.getSelectedOperation()).toBe operation + editSession = rootView.getActiveEditSession() + expect(editSession.buffer.getPath()).toBe project.resolve(operation.getPath()) + expect(editSession.getSelectedBufferRange()).toEqual operation.getBufferRange() + expect(rootView.focus).toHaveBeenCalled() diff --git a/spec/extensions/fuzzy-finder-spec.coffee b/spec/extensions/fuzzy-finder-spec.coffee index fb5c1a999..d5b2d6552 100644 --- a/spec/extensions/fuzzy-finder-spec.coffee +++ b/spec/extensions/fuzzy-finder-spec.coffee @@ -122,7 +122,7 @@ describe 'FuzzyFinder', -> describe "when the active editor only contains edit sessions for anonymous buffers", -> it "does not open", -> editor = rootView.getActiveEditor() - editor.edit(rootView.project.open()) + editor.edit(rootView.project.buildEditSessionForPath()) editor.loadPreviousEditSession() editor.destroyActiveEditSession() expect(editor.getOpenBufferPaths().length).toBe 0 diff --git a/spec/extensions/tree-view-spec.coffee b/spec/extensions/tree-view-spec.coffee index 6d120c132..ca073ed51 100644 --- a/spec/extensions/tree-view-spec.coffee +++ b/spec/extensions/tree-view-spec.coffee @@ -31,12 +31,12 @@ describe "TreeView", -> expect(treeView.root.find('> .header .name')).toHaveText('fixtures/') rootEntries = treeView.root.find('.entries') - subdir1 = rootEntries.find('> li:eq(0)') - expect(subdir1.find('.disclosure-arrow')).toHaveText('▸') - expect(subdir1.find('.name')).toHaveText('dir/') - expect(subdir1.find('.entries')).not.toExist() + subdir0 = rootEntries.find('> li:eq(0)') + expect(subdir0.find('.disclosure-arrow')).toHaveText('▸') + expect(subdir0.find('.name')).toHaveText('dir/') + expect(subdir0.find('.entries')).not.toExist() - subdir2 = rootEntries.find('> li:eq(1)') + subdir2 = rootEntries.find('> li:eq(2)') expect(subdir2.find('.disclosure-arrow')).toHaveText('▸') expect(subdir2.find('.name')).toHaveText('zed/') expect(subdir2.find('.entries')).not.toExist() @@ -610,8 +610,6 @@ describe "TreeView", -> describe "when the directories along the new path don't exist", -> it "creates the target directory before moving the file", -> - rootView.project.destroy() # Ensure there are no open buffers (renaming a file asynchronously updates the buffer's path, this causes the afterEach block to unwatch the previous path, which no longer exists.) - newPath = fs.join(rootDirPath, 'new/directory', 'renamed-test-file.txt') moveDialog.miniEditor.setText(newPath) @@ -620,6 +618,8 @@ describe "TreeView", -> expect(fs.exists(newPath)).toBeTruthy() expect(fs.exists(filePath)).toBeFalsy() + waits 50 # TODO: remove this workaround once we fix the race condition in fs events + describe "when a file or directory already exists at the target path", -> it "shows an error message and does not close the dialog", -> runs -> diff --git a/spec/fixtures/dir/a b/spec/fixtures/dir/a index e69de29bb..9bda1cbd0 100644 --- a/spec/fixtures/dir/a +++ b/spec/fixtures/dir/a @@ -0,0 +1,2 @@ +aaa bbb +cc aa cc diff --git a/spec/fixtures/dir/a-dir/oh-git b/spec/fixtures/dir/a-dir/oh-git index e69de29bb..64998f776 100644 --- a/spec/fixtures/dir/a-dir/oh-git +++ b/spec/fixtures/dir/a-dir/oh-git @@ -0,0 +1 @@ +bbb aaaa \ No newline at end of file diff --git a/spec/fixtures/dir/b b/spec/fixtures/dir/b index e69de29bb..f2c4fd35c 100644 --- a/spec/fixtures/dir/b +++ b/spec/fixtures/dir/b @@ -0,0 +1 @@ +aaa ccc diff --git a/spec/fixtures/evil-files/a_file_with_utf8.txt b/spec/fixtures/evil-files/a_file_with_utf8.txt new file mode 100644 index 000000000..a811bd702 --- /dev/null +++ b/spec/fixtures/evil-files/a_file_with_utf8.txt @@ -0,0 +1 @@ +I am evil because there's a UTF-8 character right here: ă diff --git a/spec/fixtures/evil-files/file with spaces.txt b/spec/fixtures/evil-files/file with spaces.txt new file mode 100644 index 000000000..140c7bf61 --- /dev/null +++ b/spec/fixtures/evil-files/file with spaces.txt @@ -0,0 +1 @@ +I am evil because there are spaces in my name diff --git "a/spec/fixtures/evil-files/goddam\nnewlines" "b/spec/fixtures/evil-files/goddam\nnewlines" new file mode 100644 index 000000000..00c5620b8 --- /dev/null +++ "b/spec/fixtures/evil-files/goddam\nnewlines" @@ -0,0 +1 @@ +I am evil because there's a newline in my name diff --git "a/spec/fixtures/evil-files/quote\".txt" "b/spec/fixtures/evil-files/quote\".txt" new file mode 100644 index 000000000..5e51d8e20 --- /dev/null +++ "b/spec/fixtures/evil-files/quote\".txt" @@ -0,0 +1 @@ +I am evil because there's a " in my filename. Why you do that!? \ No newline at end of file diff --git a/spec/fixtures/evil-files/utfă.md b/spec/fixtures/evil-files/utfă.md new file mode 100644 index 000000000..782a5bf5a --- /dev/null +++ b/spec/fixtures/evil-files/utfă.md @@ -0,0 +1 @@ +I am evil because there's a UTF-8 character in my name diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 672e1e5a9..c559a1cce 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -26,6 +26,7 @@ afterEach -> $('#jasmine-content').empty() document.title = defaultTitle ensureNoPathSubscriptions() + window.fixturesProject.destroy() window.keymap.bindKeys '*', 'meta-w': 'close' $(document).on 'close', -> window.close() @@ -88,15 +89,32 @@ window.mousedownEvent = (properties={}) -> window.mousemoveEvent = (properties={}) -> window.mouseEvent('mousemove', properties) -window.waitsForPromise = (fn) -> +window.waitsForPromise = (args...) -> + if args.length > 1 + { shouldReject } = args[0] + else + shouldReject = false + fn = _.last(args) + window.waitsFor (moveOn) -> - fn().done(moveOn) + promise = fn() + if shouldReject + promise.fail(moveOn) + promise.done -> + jasmine.getEnv().currentSpec.fail("Expected promise to be rejected, but it was resolved") + moveOn() + else + promise.done(moveOn) + promise.fail (error) -> + jasmine.getEnv().currentSpec.fail("Expected promise to be resolved, but it was rejected with #{jasmine.pp(error)}") + moveOn() window.resetTimeouts = -> window.now = 0 window.timeoutCount = 0 window.timeouts = [] +window.originalSetTimeout = window.setTimeout window.setTimeout = (callback, ms) -> id = ++window.timeoutCount window.timeouts.push([id, window.now + ms, callback]) diff --git a/spec/stdlib/child-process-spec.coffee b/spec/stdlib/child-process-spec.coffee new file mode 100644 index 000000000..7d57a21cb --- /dev/null +++ b/spec/stdlib/child-process-spec.coffee @@ -0,0 +1,94 @@ +ChildProcess = require 'child-process' + +describe 'Child Processes', -> + describe ".exec(command, options)", -> + [stderrHandler, stdoutHandler] = [] + + beforeEach -> + stderrHandler = jasmine.createSpy "stderrHandler" + stdoutHandler = jasmine.createSpy "stdoutHandler" + + it "returns a promise that resolves to stdout and stderr", -> + waitsForPromise -> + cmd = "echo 'good' && echo 'bad' >&2" + ChildProcess.exec(cmd).done (stdout, stderr) -> + expect(stdout).toBe 'good\n' + expect(stderr).toBe 'bad\n' + + describe "when options are given", -> + it "calls the options.stdout callback when new data is received on stdout", -> + cmd = "echo 'first' && sleep .1 && echo 'second' && sleep .1 && echo 'third'" + ChildProcess.exec(cmd, stdout: stdoutHandler) + + waitsFor -> + stdoutHandler.callCount > 2 + + runs -> + expect(stdoutHandler.argsForCall[0][0]).toBe "first\n" + expect(stdoutHandler.argsForCall[1][0]).toBe "second\n" + expect(stdoutHandler.argsForCall[2][0]).toBe "third\n" + + it "calls the options.stderr callback when new data is received on stderr", -> + cmd = "echo '1111' >&2 && sleep .1 && echo '2222' >&2" + ChildProcess.exec(cmd, stderr: stderrHandler) + + waitsFor -> + stderrHandler.callCount > 1 + + runs -> + expect(stderrHandler.argsForCall[0][0]).toBe "1111\n" + expect(stderrHandler.argsForCall[1][0]).toBe "2222\n" + + describe "when the `bufferLines` option is true ", -> + [simulateStdout, simulateStderr] = [] + + beforeEach -> + spyOn($native, 'exec') + ChildProcess.exec("print_the_things", bufferLines: true, stdout: stdoutHandler, stderr: stderrHandler) + { stdout, stderr } = $native.exec.argsForCall[0][1] + simulateStdout = stdout + simulateStderr = stderr + + it "only triggers stdout callbacks with complete lines", -> + simulateStdout """ + I am a full line + I am part of """ + + expect(stdoutHandler).toHaveBeenCalledWith("I am a full line\n") + stdoutHandler.reset() + + simulateStdout """ + a line + I am another full line\n + """ + + expect(stdoutHandler).toHaveBeenCalledWith """ + I am part of a line + I am another full line\n + """ + + it "only triggers stderr callbacks with complete lines", -> + simulateStderr """ + I am a full line + I am part of """ + + expect(stderrHandler).toHaveBeenCalledWith("I am a full line\n") + stdoutHandler.reset() + + simulateStderr """ + a line + I am another full line\n + """ + + expect(stderrHandler).toHaveBeenCalledWith """ + I am part of a line + I am another full line\n + """ + + describe "when the command fails", -> + it "executes the callback with error set to the exit status", -> + waitsForPromise shouldReject: true, -> + cmd = "echo 'bad' >&2 && exit 2" + ChildProcess.exec(cmd).fail (error) -> + expect(error.exitStatus).toBe 2 + expect(error.stderr).toBe "bad\n" diff --git a/src/app/anchor-range.coffee b/src/app/anchor-range.coffee index 6340945e7..54609a67f 100644 --- a/src/app/anchor-range.coffee +++ b/src/app/anchor-range.coffee @@ -4,11 +4,13 @@ module.exports = class AnchorRange start: null end: null + buffer: null + editSession: null # optional - constructor: (@editSession, bufferRange) -> + constructor: (bufferRange, @buffer, @editSession) -> bufferRange = Range.fromObject(bufferRange) - @startAnchor = @editSession.addAnchorAtBufferPosition(bufferRange.start, ignoreEqual: true) - @endAnchor = @editSession.addAnchorAtBufferPosition(bufferRange.end) + @startAnchor = @buffer.addAnchorAtPosition(bufferRange.start, ignoreEqual: true) + @endAnchor = @buffer.addAnchorAtPosition(bufferRange.end) getBufferRange: -> new Range(@startAnchor.getBufferPosition(), @endAnchor.getBufferPosition()) @@ -22,3 +24,5 @@ class AnchorRange destroy: -> @startAnchor.destroy() @endAnchor.destroy() + @buffer.removeAnchorRange(this) + @editSession?.removeAnchorRange(this) diff --git a/src/app/anchor.coffee b/src/app/anchor.coffee index 69fad7dcd..2755974c0 100644 --- a/src/app/anchor.coffee +++ b/src/app/anchor.coffee @@ -4,12 +4,15 @@ _ = require 'underscore' module.exports = class Anchor - editor: null + buffer: null + editSession: null # optional bufferPosition: null screenPosition: null + ignoreEqual: false + strong: false - constructor: (@editSession, options = {}) -> - { @ignoreEqual, @strong } = options + constructor: (@buffer, options = {}) -> + { @editSession, @ignoreEqual, @strong } = options handleBufferChange: (e) -> { oldRange, newRange } = e @@ -43,7 +46,7 @@ class Anchor setBufferPosition: (position, options={}) -> @bufferPosition = Point.fromObject(position) clip = options.clip ? true - @bufferPosition = @editSession.clipBufferPosition(@bufferPosition) if clip + @bufferPosition = @buffer.clipPosition(@bufferPosition) if clip @refreshScreenPosition(options) getScreenPosition: -> @@ -65,11 +68,13 @@ class Anchor @trigger 'change-screen-position', @screenPosition, bufferChange: options.bufferChange refreshScreenPosition: (options={}) -> + return unless @editSession screenPosition = @editSession.screenPositionForBufferPosition(@bufferPosition, options) @setScreenPosition(screenPosition, bufferChange: options.bufferChange, clip: false, assignBufferPosition: false) destroy: -> - @editSession.removeAnchor(this) + @buffer.removeAnchor(this) + @editSession?.removeAnchor(this) @trigger 'destroy' @off() diff --git a/src/app/buffer-change-operation.coffee b/src/app/buffer-change-operation.coffee index 2aa0e6098..b9f61bec8 100644 --- a/src/app/buffer-change-operation.coffee +++ b/src/app/buffer-change-operation.coffee @@ -38,7 +38,11 @@ class BufferChangeOperation newTextLines[lastLineIndex] += suffix @buffer.replaceLines(oldRange.start.row, oldRange.end.row, newTextLines) - @buffer.trigger 'change', { oldRange, newRange, oldText, newText } + + event = { oldRange, newRange, oldText, newText } + @buffer.trigger 'change', event + anchor.handleBufferChange(event) for anchor in @buffer.getAnchors() + @buffer.trigger 'update-anchors-after-change' newRange calculateNewRange: (oldRange, newText) -> diff --git a/src/app/buffer.coffee b/src/app/buffer.coffee index fa7b0c577..31f97f681 100644 --- a/src/app/buffer.coffee +++ b/src/app/buffer.coffee @@ -6,6 +6,8 @@ Range = require 'range' EventEmitter = require 'event-emitter' UndoManager = require 'undo-manager' BufferChangeOperation = require 'buffer-change-operation' +Anchor = require 'anchor' +AnchorRange = require 'anchor-range' module.exports = class Buffer @@ -15,9 +17,14 @@ class Buffer modifiedOnDisk: null lines: null file: null + anchors: null + anchorRanges: null + refcount: 0 - constructor: (path) -> + constructor: (path, @project) -> @id = @constructor.idCounter++ + @anchors = [] + @anchorRanges = [] @lines = [''] if path @@ -31,22 +38,21 @@ class Buffer @modified = false destroy: -> + throw new Error("Destroying buffer twice with path '#{@getPath()}'") if @destroyed @file?.off() + @destroyed = true + @project?.removeBuffer(this) - reload: -> - @setText(fs.read(@file.getPath())) - @modified = false - @modifiedOnDisk = false + retain: -> + @refcount++ + this - getPath: -> - @file?.getPath() - - setPath: (path) -> - return if path == @getPath() - - @file?.off() - @file = new File(path) + release: -> + @refcount-- + @destroy() if @refcount <= 0 + this + subscribeToFile: -> @file.on "contents-change", => if @isModified() @modifiedOnDisk = true @@ -61,6 +67,20 @@ class Buffer @file.on "move", => @trigger "path-change", this + reload: -> + @setText(fs.read(@file.getPath())) + @modified = false + @modifiedOnDisk = false + + getPath: -> + @file?.getPath() + + setPath: (path) -> + return if path == @getPath() + + @file?.off() + @file = new File(path) + @subscribeToFile() @trigger "path-change", this getExtension: -> @@ -151,6 +171,15 @@ class Buffer operation = new BufferChangeOperation({buffer: this, oldRange, newText}) @pushOperation(operation) + clipPosition: (position) -> + { row, column } = Point.fromObject(position) + row = 0 if row < 0 + column = 0 if column < 0 + row = Math.min(@getLastRow(), row) + column = Math.min(@lineLengthForRow(row), column) + + new Point(row, column) + prefixAndSuffixForRange: (range) -> prefix: @lines[range.start.row][0...range.start.column] suffix: @lines[range.end.row][range.end.column..] @@ -194,6 +223,29 @@ class Buffer isModified: -> @modified + getAnchors: -> new Array(@anchors...) + + addAnchor: (options) -> + anchor = new Anchor(this, options) + @anchors.push(anchor) + anchor + + addAnchorAtPosition: (position, options) -> + anchor = @addAnchor(options) + anchor.setBufferPosition(position) + anchor + + addAnchorRange: (range, editSession) -> + anchorRange = new AnchorRange(range, this, editSession) + @anchorRanges.push(anchorRange) + anchorRange + + removeAnchor: (anchor) -> + _.remove(@anchors, anchor) + + removeAnchorRange: (anchorRange) -> + _.remove(@anchorRanges, anchorRange) + matchesInCharacterRange: (regex, startIndex, endIndex) -> text = @getText() matches = [] diff --git a/src/app/cursor.coffee b/src/app/cursor.coffee index a7f3a736f..af26c65c0 100644 --- a/src/app/cursor.coffee +++ b/src/app/cursor.coffee @@ -18,7 +18,7 @@ class Cursor @setBufferPosition(bufferPosition) if bufferPosition destroy: -> - @editSession.removeAnchor(@anchor) + @anchor.destroy() @editSession.removeCursor(this) @trigger 'destroy' diff --git a/src/app/edit-session.coffee b/src/app/edit-session.coffee index 2632abc31..67b1a8857 100644 --- a/src/app/edit-session.coffee +++ b/src/app/edit-session.coffee @@ -5,6 +5,7 @@ DisplayBuffer = require 'display-buffer' Cursor = require 'cursor' Selection = require 'selection' EventEmitter = require 'event-emitter' +Range = require 'range' AnchorRange = require 'anchor-range' _ = require 'underscore' @@ -13,7 +14,7 @@ class EditSession @idCounter: 1 @deserialize: (state, project) -> - session = project.open(state.buffer) + session = project.buildEditSessionForPath(state.buffer) session.setScrollTop(state.scrollTop) session.setScrollLeft(state.scrollLeft) session.setCursorScreenPosition(state.cursorScreenPosition) @@ -41,11 +42,11 @@ class EditSession @selections = [] @addCursorAtScreenPosition([0, 0]) + @buffer.retain() @buffer.on "path-change.edit-session-#{@id}", => @trigger 'buffer-path-change' - @buffer.on "change.edit-session-#{@id}", (e) => - anchor.handleBufferChange(e) for anchor in @getAnchors() + @buffer.on "update-anchors-after-change.edit-session-#{@id}", => @mergeCursors() @displayBuffer.on "change.edit-session-#{@id}", (e) => @@ -54,10 +55,16 @@ class EditSession anchor.refreshScreenPosition() for anchor in @getAnchors() destroy: -> + throw new Error("Edit session already destroyed") if @destroyed + @destroyed = true + @buffer.off ".edit-session-#{@id}" + @buffer.release() @displayBuffer.off ".edit-session-#{@id}" @displayBuffer.destroy() @project.removeEditSession(this) + anchor.destroy() for anchor in @getAnchors() + anchorRange.destroy() for anchorRange in @getAnchorRanges() serialize: -> buffer: @buffer.getPath() @@ -88,14 +95,8 @@ class EditSession getSoftWrap: -> @softWrap setSoftWrap: (@softWrap) -> - clipBufferPosition: (bufferPosition, options) -> - { row, column } = Point.fromObject(bufferPosition) - row = 0 if row < 0 - column = 0 if column < 0 - row = Math.min(@buffer.getLastRow(), row) - column = Math.min(@buffer.lineLengthForRow(row), column) - - new Point(row, column) + clipBufferPosition: (bufferPosition) -> + @buffer.clipPosition(bufferPosition) getFileExtension: -> @buffer.getExtension() getPath: -> @buffer.getPath() @@ -268,8 +269,11 @@ class EditSession getAnchors: -> new Array(@anchors...) - addAnchor: (options) -> - anchor = new Anchor(this, options) + getAnchorRanges: -> + new Array(@anchorRanges...) + + addAnchor: (options={}) -> + anchor = @buffer.addAnchor(_.extend({editSession: this}, options)) @anchors.push(anchor) anchor @@ -279,13 +283,16 @@ class EditSession anchor addAnchorRange: (range) -> - anchorRange = new AnchorRange(this, range) + anchorRange = @buffer.addAnchorRange(range, this) @anchorRanges.push(anchorRange) anchorRange removeAnchor: (anchor) -> _.remove(@anchors, anchor) + removeAnchorRange: (anchorRange) -> + _.remove(@anchorRanges, anchorRange) + getCursors: -> new Array(@cursors...) getCursor: (index=0) -> @@ -317,19 +324,27 @@ class EditSession addSelectionForBufferRange: (bufferRange, options) -> @addCursor().selection.setBufferRange(bufferRange, options) + @mergeIntersectingSelections() setSelectedBufferRange: (bufferRange, options) -> - @clearSelections() - @getLastSelection().setBufferRange(bufferRange, options) + @setSelectedBufferRanges([bufferRange], options) + + setSelectedBufferRanges: (bufferRanges, options={}) -> + throw new Error("Passed an empty array to setSelectedBufferRanges") unless bufferRanges.length - setSelectedBufferRanges: (bufferRanges, options) -> selections = @getSelections() + selection.destroy() for selection in selections[bufferRanges.length...] + for bufferRange, i in bufferRanges + bufferRange = Range.fromObject(bufferRange) + unless options.preserveFolds + for row in [bufferRange.start.row..bufferRange.end.row] + @destroyFoldsContainingBufferRow(row) if selections[i] selections[i].setBufferRange(bufferRange, options) else @addSelectionForBufferRange(bufferRange, options) - @mergeIntersectingSelections() + @mergeIntersectingSelections(options) removeSelection: (selection) -> _.remove(@selections, selection) @@ -340,6 +355,9 @@ class EditSession selection.destroy() lastSelection.clear() + clearAllSelections: -> + selection.destroy() for selection in @getSelections() + getSelections: -> new Array(@selections...) getSelection: (index) -> diff --git a/src/app/editor.coffee b/src/app/editor.coffee index c71be0e67..2727f0a56 100644 --- a/src/app/editor.coffee +++ b/src/app/editor.coffee @@ -337,7 +337,8 @@ class Editor extends View @calculateDimensions() @hiddenInput.width(@charWidth) @setSoftWrapColumn() if @activeEditSession.getSoftWrap() - $(window).on "resize.editor#{@id}", => @updateRenderedLines() + $(window).on "resize.editor#{@id}", => + @updateRenderedLines() @focus() if @isFocused @renderWhenAttached() @@ -393,7 +394,7 @@ class Editor extends View for editSession, index in @editSessions if editSession.buffer.getPath() == path @setActiveEditSessionIndex(index) - return true + return @activeEditSession false getOpenBufferPaths: -> @@ -569,8 +570,11 @@ class Editor extends View if @pane() then @pane().remove() else super rootView?.focus() + getEditSessions: -> + new Array(@editSessions...) + destroyEditSessions: -> - for session in @editSessions + for session in @getEditSessions() session.destroy() renderWhenAttached: -> diff --git a/src/app/file.coffee b/src/app/file.coffee index ef87f6afb..42baf5e7b 100644 --- a/src/app/file.coffee +++ b/src/app/file.coffee @@ -9,6 +9,7 @@ class File md5: null constructor: (@path) -> + throw "Creating file with path that is not a file: #{@path}" unless fs.isFile(@path) @updateMd5() setPath: (@path) -> diff --git a/src/app/project.coffee b/src/app/project.coffee index 40ddfc523..b2aa0f006 100644 --- a/src/app/project.coffee +++ b/src/app/project.coffee @@ -1,11 +1,12 @@ fs = require 'fs' _ = require 'underscore' $ = require 'jquery' - +Range = require 'range' Buffer = require 'buffer' EditSession = require 'edit-session' EventEmitter = require 'event-emitter' Directory = require 'directory' +ChildProcess = require 'child-process' module.exports = class Project @@ -15,13 +16,22 @@ class Project autoIndent: null softTabs: null softWrap: null + ignoredPathRegexes: null constructor: (path) -> @setPath(path) @editSessions = [] + @buffers = [] @setTabText(' ') @setAutoIndent(true) @setSoftTabs(true) + @ignoredPathRegexes = [ + /\.DS_Store$/ + /(^|\/)\.git(\/|$)/ + ] + + destroy: -> + editSession.destroy() for editSession in @getEditSessions() getPath: -> @rootDirectory?.path @@ -54,7 +64,10 @@ class Project deferred ignorePath: (path) -> - fs.base(path).match(/\.DS_Store/) or path.match(/(^|\/)\.git(\/|$)/) + _.find @ignoredPathRegexes, (regex) -> path.match(regex) + + ignorePathRegex: -> + @ignoredPathRegexes.map((regex) -> "(#{regex.source})").join("|") resolve: (filePath) -> filePath = fs.join(@getPath(), filePath) unless filePath[0] == '/' @@ -75,14 +88,8 @@ class Project getSoftWrap: -> @softWrap setSoftWrap: (@softWrap) -> - open: (filePath, editSessionOptions={}) -> - if filePath? - filePath = @resolve(filePath) - buffer = @bufferWithPath(filePath) ? @buildBuffer(filePath) - else - buffer = @buildBuffer() - - @buildEditSession(buffer, editSessionOptions) + buildEditSessionForPath: (filePath, editSessionOptions={}) -> + @buildEditSession(@bufferForPath(filePath), editSessionOptions) buildEditSession: (buffer, editSessionOptions) -> options = _.extend(@defaultEditSessionOptions(), editSessionOptions) @@ -99,22 +106,11 @@ class Project softTabs: @getSoftTabs() softWrap: @getSoftWrap() - destroy: -> - for editSession in _.clone(@editSessions) - @removeEditSession(editSession) + getEditSessions: -> + new Array(@editSessions...) removeEditSession: (editSession) -> _.remove(@editSessions, editSession) - @destroyBufferIfOrphaned(editSession.buffer) - - destroyBufferIfOrphaned: (buffer) -> - unless _.find(@editSessions, (editSession) -> editSession.buffer == buffer) - buffer.destroy() - - buildBuffer: (filePath) -> - buffer = new Buffer(filePath) - @trigger 'new-buffer', buffer - buffer getBuffers: -> buffers = [] @@ -123,7 +119,64 @@ class Project buffers - bufferWithPath: (path) -> - return editSession.buffer for editSession in @editSessions when editSession.buffer.getPath() == path + bufferForPath: (filePath) -> + if filePath? + filePath = @resolve(filePath) + buffer = _.find @buffers, (buffer) -> buffer.getPath() == filePath + buffer or @buildBuffer(filePath) + else + @buildBuffer() + + buildBuffer: (filePath) -> + buffer = new Buffer(filePath, this) + @buffers.push buffer + @trigger 'new-buffer', buffer + buffer + + removeBuffer: (buffer) -> + _.remove(@buffers, buffer) + + scan: (regex, iterator) -> + regex = new RegExp(regex.source, 'g') + command = "#{require.resolve('ag')} --ackmate \"#{regex.source}\" \"#{@getPath()}\"" + bufferedData = "" + + state = 'readingPath' + path = null + + readPath = (line) -> + if /^[0-9,; ]+:/.test(line) + state = 'readingLines' + else if /^:/.test line + path = line.substr(1) + else + path += ('\n' + line) + + readLine = (line) -> + if line.length == 0 + state = 'readingPath' + path = null + else + colonIndex = line.indexOf(':') + matchInfo = line.substring(0, colonIndex) + lineText = line.substring(colonIndex + 1) + readMatches(matchInfo, lineText) + + readMatches = (matchInfo, lineText) -> + [lineNumber, matchPositionsText] = matchInfo.match(/(\d+);(.+)/)[1..] + row = parseInt(lineNumber) - 1 + matchPositions = matchPositionsText.split(',').map (positionText) -> positionText.split(' ').map (pos) -> parseInt(pos) + + for [column, length] in matchPositions + range = new Range([row, column], [row, column + length]) + match = lineText.substr(column, length) + iterator({path, range, match}) + + ChildProcess.exec command , bufferLines: true, stdout: (data) -> + lines = data.split('\n') + lines.pop() # the last segment is a spurios '' because data always ends in \n due to bufferLines: true + for line in lines + readPath(line) if state is 'readingPath' + readLine(line) if state is 'readingLines' _.extend Project.prototype, EventEmitter diff --git a/src/app/root-view.coffee b/src/app/root-view.coffee index d066bd677..2f9e4b9bd 100644 --- a/src/app/root-view.coffee +++ b/src/app/root-view.coffee @@ -96,8 +96,9 @@ class RootView extends View changeFocus = options.changeFocus ? true allowActiveEditorChange = options.allowActiveEditorChange ? false - unless @openInExistingEditor(path, allowActiveEditorChange) - editor = new Editor(editSession: @project.open(path)) + unless editSession = @openInExistingEditor(path, allowActiveEditorChange) + editSession = @project.buildEditSessionForPath(path) + editor = new Editor({editSession}) pane = new Pane(editor) @panes.append(pane) if changeFocus @@ -105,23 +106,24 @@ class RootView extends View else @makeEditorActive(editor) + editSession + openInExistingEditor: (path, allowActiveEditorChange) -> if activeEditor = @getActiveEditor() path = @project.resolve(path) if path - if activeEditor.activateEditSessionForPath(path) - return true + if editSession = activeEditor.activateEditSessionForPath(path) + return editSession if allowActiveEditorChange for editor in @getEditors() - if editor.activateEditSessionForPath(path) + if editSession = editor.activateEditSessionForPath(path) editor.focus() - return true + return editSession - activeEditor.edit(@project.open(path)) - true - else - false + editSession = @project.buildEditSessionForPath(path) + activeEditor.edit(editSession) + editSession editorFocused: (editor) -> @makeEditorActive(editor) if @panes.containsElement(editor) @@ -144,7 +146,7 @@ class RootView extends View document.title = title getEditors: -> - @panes.find('.editor').map(-> $(this).view()).toArray() + @panes.find('.pane > .editor').map(-> $(this).view()).toArray() getModifiedBuffers: -> modifiedBuffers = [] @@ -163,6 +165,9 @@ class RootView extends View else @panes.find('.editor:first').view() + getActiveEditSession: -> + @getActiveEditor()?.activeEditSession + focusNextPane: -> panes = @panes.find('.pane') currentIndex = panes.toArray().indexOf(@getFocusedPane()[0]) diff --git a/src/app/selection.coffee b/src/app/selection.coffee index ffe5c58b4..85819ef6b 100644 --- a/src/app/selection.coffee +++ b/src/app/selection.coffee @@ -21,6 +21,7 @@ class Selection if @cursor @cursor.off('.selection') @cursor.destroy() + @anchor?.destroy() @editSession.removeSelection(this) @trigger 'destroy' diff --git a/src/app/window.coffee b/src/app/window.coffee index 35382f5a0..93f748833 100644 --- a/src/app/window.coffee +++ b/src/app/window.coffee @@ -83,6 +83,12 @@ windowAdditions = onerror: -> $native.showDevTools() + measure: (description, fn) -> + start = new Date().getTime() + fn() + result = new Date().getTime() - start + console.log description, result + window[key] = value for key, value of windowAdditions window.setUpKeymap() diff --git a/src/extensions/autocomplete/autocomplete.coffee b/src/extensions/autocomplete/autocomplete.coffee index 5706482a2..5a8ea7845 100644 --- a/src/extensions/autocomplete/autocomplete.coffee +++ b/src/extensions/autocomplete/autocomplete.coffee @@ -27,7 +27,7 @@ class Autocomplete extends View @activate: (rootView) -> new Autocomplete(editor) for editor in rootView.getEditors() rootView.on 'editor-open', (e, editor) -> - new Autocomplete(editor) unless editor.is('.autocomplete .mini') + editor.autoComplete = new Autocomplete(editor) unless editor.is('.autocomplete .mini') initialize: (@editor) -> requireStylesheet 'autocomplete.css' diff --git a/src/extensions/command-panel/command-interpreter.coffee b/src/extensions/command-panel/command-interpreter.coffee index 0190a5e71..eb328c9b6 100644 --- a/src/extensions/command-panel/command-interpreter.coffee +++ b/src/extensions/command-panel/command-interpreter.coffee @@ -3,16 +3,16 @@ PEG = require 'pegjs' module.exports = class CommandInterpreter - constructor: -> + constructor: (@project) -> @parser = PEG.buildParser(fs.read(require.resolve 'command-panel/commands.pegjs')) - eval: (editor, string) -> + eval: (string, activeEditSession) -> compositeCommand = @parser.parse(string) @lastRelativeAddress = compositeCommand if compositeCommand.isRelativeAddress() - compositeCommand.execute(editor) + compositeCommand.execute(@project, activeEditSession) - repeatRelativeAddress: (editor) -> - @lastRelativeAddress?.execute(editor) + repeatRelativeAddress: (activeEditSession) -> + @lastRelativeAddress?.execute(@project, activeEditSession) - repeatRelativeAddressInReverse: (editor) -> - @lastRelativeAddress?.reverse().execute(editor) + repeatRelativeAddressInReverse: (activeEditSession) -> + @lastRelativeAddress?.reverse().execute(@project, activeEditSession) diff --git a/src/extensions/command-panel/command-panel.coffee b/src/extensions/command-panel/command-panel.coffee index b781cff31..bf00695d0 100644 --- a/src/extensions/command-panel/command-panel.coffee +++ b/src/extensions/command-panel/command-panel.coffee @@ -1,7 +1,8 @@ -{View} = require 'space-pen' +{View, $$$} = require 'space-pen' CommandInterpreter = require 'command-panel/command-interpreter' RegexAddress = require 'command-panel/commands/regex-address' CompositeCommand = require 'command-panel/commands/composite-command' +PreviewList = require 'command-panel/preview-list' Editor = require 'editor' {SyntaxError} = require('pegjs').parser @@ -16,6 +17,9 @@ class CommandPanel extends View else @instance = new CommandPanel(rootView) + @deactivate: -> + @instance.destroy() + @serialize: -> text: @instance.miniEditor.getText() visible: @instance.hasParent() @@ -25,23 +29,29 @@ class CommandPanel extends View commandPanel.attach(state.text) if state.visible commandPanel - @content: -> + @content: (rootView) -> @div class: 'command-panel', => - @div ':', class: 'prompt', outlet: 'prompt' - @subview 'miniEditor', new Editor(mini: true) + @subview 'previewList', new PreviewList(rootView) + @div class: 'prompt-and-editor', => + @div ':', class: 'prompt', outlet: 'prompt' + @subview 'miniEditor', new Editor(mini: true) commandInterpreter: null history: null historyIndex: 0 initialize: (@rootView)-> - @commandInterpreter = new CommandInterpreter() + @commandInterpreter = new CommandInterpreter(@rootView.project) @history = [] @on 'command-panel:unfocus', => @rootView.focus() + @on 'command-panel:close', => @detach() + @rootView.on 'command-panel:toggle', => @toggle() + @rootView.on 'command-panel:toggle-preview', => @togglePreview() @rootView.on 'command-panel:execute', => @execute() @rootView.on 'command-panel:find-in-file', => @attach("/") + @rootView.on 'command-panel:find-in-project', => @attach("Xx/") @rootView.on 'command-panel:repeat-relative-address', => @repeatRelativeAddress() @rootView.on 'command-panel:repeat-relative-address-in-reverse', => @repeatRelativeAddressInReverse() @rootView.on 'command-panel:set-selection-as-regex-address', => @setSelectionAsLastRelativeAddress() @@ -50,6 +60,11 @@ class CommandPanel extends View @miniEditor.on 'move-up', => @navigateBackwardInHistory() @miniEditor.on 'move-down', => @navigateForwardInHistory() + @previewList.hide() + + destroy: -> + @previewList.destroy() + toggle: -> if @miniEditor.isFocused @detach() @@ -58,30 +73,46 @@ class CommandPanel extends View @attach() unless @hasParent() @miniEditor.focus() + togglePreview: -> + if @previewList.is(':focus') + @previewList.hide() + @detach() + @rootView.focus() + else + @attach() unless @hasParent() + if @previewList.hasOperations() + @previewList.show().focus() + else + @miniEditor.focus() + attach: (text='') -> @rootView.vertical.append(this) @miniEditor.focus() @miniEditor.setText(text) - @prompt.css 'font', @miniEditor.css('font') + @miniEditor.setCursorBufferPosition([0, Infinity]) detach: -> @rootView.focus() + @previewList.hide() super execute: (command = @miniEditor.getText()) -> try - @commandInterpreter.eval(@rootView.getActiveEditor(), command) + @commandInterpreter.eval(command, @rootView.getActiveEditSession()).done (operationsToPreview) => + @history.push(command) + @historyIndex = @history.length + if operationsToPreview?.length + @previewList.populate(operationsToPreview) + @previewList.focus() + else + @detach() catch error - if error instanceof SyntaxError + if error.name is "SyntaxError" @flashError() return else throw error - @history.push(command) - @historyIndex = @history.length - @detach() - navigateBackwardInHistory: -> return if @historyIndex == 0 @historyIndex-- @@ -93,10 +124,10 @@ class CommandPanel extends View @miniEditor.setText(@history[@historyIndex] or '') repeatRelativeAddress: -> - @commandInterpreter.repeatRelativeAddress(@rootView.getActiveEditor()) + @commandInterpreter.repeatRelativeAddress(@rootView.getActiveEditSession()) repeatRelativeAddressInReverse: -> - @commandInterpreter.repeatRelativeAddressInReverse(@rootView.getActiveEditor()) + @commandInterpreter.repeatRelativeAddressInReverse(@rootView.getActiveEditSession()) setSelectionAsLastRelativeAddress: -> selection = @rootView.getActiveEditor().getSelectedText() diff --git a/src/extensions/command-panel/commands.pegjs b/src/extensions/command-panel/commands.pegjs index 1a3b92307..7761003eb 100644 --- a/src/extensions/command-panel/commands.pegjs +++ b/src/extensions/command-panel/commands.pegjs @@ -1,12 +1,14 @@ { var CompositeCommand = require('command-panel/commands/composite-command') var Substitution = require('command-panel/commands/substitution'); + var ZeroAddress = require('command-panel/commands/zero-address'); + var EofAddress = require('command-panel/commands/eof-address'); var LineAddress = require('command-panel/commands/line-address'); var AddressRange = require('command-panel/commands/address-range'); - var EofAddress = require('command-panel/commands/eof-address'); var CurrentSelectionAddress = require('command-panel/commands/current-selection-address') var RegexAddress = require('command-panel/commands/regex-address') var SelectAllMatches = require('command-panel/commands/select-all-matches') + var SelectAllMatchesInProject = require('command-panel/commands/select-all-matches-in-project') } start = expressions:(expression+) { @@ -19,21 +21,22 @@ address = addressRange / primitiveAddress addressRange = start:primitiveAddress? _ ',' _ end:address? { - if (!start) start = new LineAddress(0) + if (!start) start = new ZeroAddress() if (!end) end = new EofAddress() return new AddressRange(start, end) } primitiveAddress - = lineNumber:integer { return new LineAddress(lineNumber) } + = '0' { return new ZeroAddress() } / '$' { return new EofAddress() } / '.' { return new CurrentSelectionAddress() } + / lineNumber:integer { return new LineAddress(lineNumber) } / regexAddress regexAddress = reverse:'-'? '/' pattern:pattern '/'? { return new RegexAddress(pattern, reverse.length > 0)} -command = substitution / selectAllMatches +command = substitution / selectAllMatches / selectAllMatchesInProject substitution = "s" _ "/" find:pattern "/" replace:pattern "/" _ options:[g]* { @@ -43,6 +46,9 @@ substitution selectAllMatches = 'x' _ '/' pattern:pattern '/'? { return new SelectAllMatches(pattern) } +selectAllMatchesInProject + = 'X' _ 'x' _ '/' pattern:pattern '/'? { return new SelectAllMatchesInProject(pattern) } + pattern = pattern:[^/]* { return pattern.join('') } diff --git a/src/extensions/command-panel/commands/address-range.coffee b/src/extensions/command-panel/commands/address-range.coffee index e8f3c68dd..45cb5afda 100644 --- a/src/extensions/command-panel/commands/address-range.coffee +++ b/src/extensions/command-panel/commands/address-range.coffee @@ -5,8 +5,8 @@ module.exports = class AddressRange extends Address constructor: (@startAddress, @endAddress) -> - getRange: (editor, currentRange) -> - new Range(@startAddress.getRange(editor, currentRange).start, @endAddress.getRange(editor, currentRange).end) + getRange: (buffer, range) -> + new Range(@startAddress.getRange(buffer, range).start, @endAddress.getRange(buffer, range).end) isRelative: -> @startAddress.isRelative() and @endAddress.isRelative() diff --git a/src/extensions/command-panel/commands/address.coffee b/src/extensions/command-panel/commands/address.coffee index e6ef1e11b..b15c07d63 100644 --- a/src/extensions/command-panel/commands/address.coffee +++ b/src/extensions/command-panel/commands/address.coffee @@ -1,8 +1,17 @@ Command = require 'command-panel/commands/command' +Operation = require 'command-panel/operation' +$ = require 'jquery' module.exports = class Address extends Command - execute: (editor, currentRange) -> - [@getRange(editor, currentRange)] + compile: (project, buffer, ranges) -> + deferred = $.Deferred() + deferred.resolve ranges.map (range) => + new Operation + project: project + buffer: buffer + bufferRange: @getRange(buffer, range) + + deferred.promise() isAddress: -> true diff --git a/src/extensions/command-panel/commands/command.coffee b/src/extensions/command-panel/commands/command.coffee index 5c6487384..181def85b 100644 --- a/src/extensions/command-panel/commands/command.coffee +++ b/src/extensions/command-panel/commands/command.coffee @@ -3,4 +3,5 @@ _ = require 'underscore' module.exports = class Command isAddress: -> false - restoreSelections: false + preserveSelections: false + previewOperations: false \ No newline at end of file diff --git a/src/extensions/command-panel/commands/composite-command.coffee b/src/extensions/command-panel/commands/composite-command.coffee index 90323ef19..395c4ce11 100644 --- a/src/extensions/command-panel/commands/composite-command.coffee +++ b/src/extensions/command-panel/commands/composite-command.coffee @@ -1,23 +1,36 @@ _ = require 'underscore' +$ = require 'jquery' module.exports = class CompositeCommand constructor: (@subcommands) -> - execute: (editor) -> - initialRanges = editor.getSelectedBufferRanges() - for command in @subcommands - newRanges = [] - currentRanges = editor.getSelectedBufferRanges() - for currentRange in currentRanges - newRanges.push(command.execute(editor, currentRange)...) + execute: (project, editSession) -> + currentRanges = editSession?.getSelectedBufferRanges() + @executeCommands(@subcommands, project, editSession, currentRanges) - for range in newRanges - for row in [range.start.row..range.end.row] - editor.destroyFoldsContainingBufferRow(row) + executeCommands: (commands, project, editSession, ranges) -> + deferred = $.Deferred() + [currentCommand, remainingCommands...] = commands - editor.setSelectedBufferRanges(newRanges) - editor.setSelectedBufferRanges(initialRanges) if command.restoreSelections + currentCommand.compile(project, editSession?.buffer, ranges).done (operations) => + if remainingCommands.length + nextRanges = operations.map (operation) -> + operation.destroy() + operation.getBufferRange() + @executeCommands(remainingCommands, project, editSession, nextRanges).done -> + deferred.resolve() + else + if currentCommand.previewOperations + deferred.resolve(operations) + else + editSession?.clearAllSelections() unless currentCommand.preserveSelections + for operation in operations + operation.execute(editSession) + operation.destroy() + deferred.resolve() + + deferred.promise() reverse: -> new CompositeCommand(@subcommands.map (command) -> command.reverse()) diff --git a/src/extensions/command-panel/commands/current-selection-address.coffee b/src/extensions/command-panel/commands/current-selection-address.coffee index fe4b67f2b..5a230f842 100644 --- a/src/extensions/command-panel/commands/current-selection-address.coffee +++ b/src/extensions/command-panel/commands/current-selection-address.coffee @@ -3,7 +3,7 @@ Range = require 'range' module.exports = class CurrentSelectionAddress extends Address - getRange: (editor, currentRange) -> - currentRange + getRange: (buffer, range) -> + range isRelative: -> true diff --git a/src/extensions/command-panel/commands/eof-address.coffee b/src/extensions/command-panel/commands/eof-address.coffee index c4f41b577..f84213420 100644 --- a/src/extensions/command-panel/commands/eof-address.coffee +++ b/src/extensions/command-panel/commands/eof-address.coffee @@ -3,9 +3,8 @@ Range = require 'range' module.exports = class EofAddress extends Address - getRange: (editor) -> - lastRow = editor.getLastBufferRow() - column = editor.lineLengthForBufferRow(lastRow) - new Range([lastRow, column], [lastRow, column]) + getRange: (buffer, range) -> + eof = buffer.getEofPosition() + new Range(eof, eof) isRelative: -> false diff --git a/src/extensions/command-panel/commands/regex-address.coffee b/src/extensions/command-panel/commands/regex-address.coffee index 8fbf13b78..37a4737ed 100644 --- a/src/extensions/command-panel/commands/regex-address.coffee +++ b/src/extensions/command-panel/commands/regex-address.coffee @@ -10,25 +10,25 @@ class RegexAddress extends Address @isReversed = isReversed @regex = new RegExp(pattern) - getRange: (editor, currentRange) -> - rangeBefore = new Range([0, 0], currentRange.start) - rangeAfter = new Range(currentRange.end, editor.getEofPosition()) + getRange: (buffer, range) -> + rangeBefore = new Range([0, 0], range.start) + rangeAfter = new Range(range.end, buffer.getEofPosition()) rangeToSearch = if @isReversed then rangeBefore else rangeAfter rangeToReturn = null scanMethodName = if @isReversed then "backwardsScanInRange" else "scanInRange" - editor[scanMethodName] @regex, rangeToSearch, (match, range) -> + buffer[scanMethodName] @regex, rangeToSearch, (match, range) -> rangeToReturn = range if rangeToReturn rangeToReturn else rangeToSearch = if @isReversed then rangeAfter else rangeBefore - editor[scanMethodName] @regex, rangeToSearch, (match, range) -> + buffer[scanMethodName] @regex, rangeToSearch, (match, range) -> rangeToReturn = range - rangeToReturn or currentRange + rangeToReturn or range isRelative: -> true diff --git a/src/extensions/command-panel/commands/select-all-matches-in-project.coffee b/src/extensions/command-panel/commands/select-all-matches-in-project.coffee new file mode 100644 index 000000000..6e09abbe9 --- /dev/null +++ b/src/extensions/command-panel/commands/select-all-matches-in-project.coffee @@ -0,0 +1,24 @@ +Command = require 'command-panel/commands/command' +Operation = require 'command-panel/operation' +$ = require 'jquery' + +module.exports = +class SelectAllMatchesInProject extends Command + regex: null + previewOperations: true + + constructor: (pattern) -> + @regex = new RegExp(pattern, 'g') + + compile: (project, buffer, range) -> + deferred = $.Deferred() + operations = [] + promise = project.scan @regex, ({path, range}) -> + operations.push(new Operation( + project: project + buffer: project.bufferForPath(path) + bufferRange: range + )) + + promise.done -> deferred.resolve(operations) + deferred.promise() diff --git a/src/extensions/command-panel/commands/select-all-matches.coffee b/src/extensions/command-panel/commands/select-all-matches.coffee index 0c8163e5f..fb5017f25 100644 --- a/src/extensions/command-panel/commands/select-all-matches.coffee +++ b/src/extensions/command-panel/commands/select-all-matches.coffee @@ -1,5 +1,6 @@ Command = require 'command-panel/commands/command' -Range = require 'range' +Operation = require 'command-panel/operation' +$ = require 'jquery' module.exports = class SelectAllMatches extends Command @@ -8,8 +9,15 @@ class SelectAllMatches extends Command constructor: (pattern) -> @regex = new RegExp(pattern, 'g') - execute: (editor, currentRange) -> - rangesToSelect = [] - editor.scanInRange @regex, currentRange, (match, range) -> - rangesToSelect.push(range) - rangesToSelect + compile: (project, buffer, ranges) -> + deferred = $.Deferred() + operations = [] + for range in ranges + buffer.scanInRange @regex, range, (match, matchRange) -> + operations.push(new Operation( + project: project + buffer: buffer + bufferRange: matchRange + )) + deferred.resolve(operations) + deferred.promise() diff --git a/src/extensions/command-panel/commands/substitution.coffee b/src/extensions/command-panel/commands/substitution.coffee index 4ba6f2ff9..403cb3731 100644 --- a/src/extensions/command-panel/commands/substitution.coffee +++ b/src/extensions/command-panel/commands/substitution.coffee @@ -1,16 +1,28 @@ Command = require 'command-panel/commands/command' +Operation = require 'command-panel/operation' +$ = require 'jquery' module.exports = class Substitution extends Command regex: null replacementText: null - restoreSelections: true + preserveSelections: true constructor: (pattern, replacementText, options) -> @replacementText = replacementText @regex = new RegExp(pattern, options.join('')) - execute: (editor, currentRange) -> - editor.scanInRange @regex, currentRange, (match, matchRange, { replace }) => - replace(@replacementText) - [currentRange] + compile: (project, buffer, ranges) -> + deferred = $.Deferred() + operations = [] + for range in ranges + buffer.scanInRange @regex, range, (match, matchRange, { replace }) => + operations.push(new Operation( + project: project + buffer: buffer + bufferRange: matchRange + newText: @replacementText + preserveSelection: true + )) + deferred.resolve(operations) + deferred.promise() diff --git a/src/extensions/command-panel/commands/zero-address.coffee b/src/extensions/command-panel/commands/zero-address.coffee new file mode 100644 index 000000000..f40b6d377 --- /dev/null +++ b/src/extensions/command-panel/commands/zero-address.coffee @@ -0,0 +1,9 @@ +Address = require 'command-panel/commands/address' +Range = require 'range' + +module.exports = +class ZeroAddress extends Address + getRange: -> + new Range([0, 0], [0, 0]) + + isRelative: -> false diff --git a/src/extensions/command-panel/keymap.coffee b/src/extensions/command-panel/keymap.coffee index 825394d8a..8b515ddcb 100644 --- a/src/extensions/command-panel/keymap.coffee +++ b/src/extensions/command-panel/keymap.coffee @@ -1,11 +1,11 @@ window.keymap.bindKeys '*' 'ctrl-0': 'command-panel:toggle' - 'ctrl-meta-0': 'command-panel:toggle-preview' + 'ctrl-2': 'command-panel:toggle-preview' 'meta-:': 'command-panel:toggle' 'meta-F': 'command-panel:find-in-project' -window.keymap.bindKeys '.command-panel .editor input', - 'meta-w': 'command-panel:toggle' +window.keymap.bindKeys '.command-panel .preview-list, .command-panel .editor input', + 'meta-w': 'command-panel:close' escape: 'command-panel:unfocus' enter: 'command-panel:execute' @@ -14,3 +14,4 @@ window.keymap.bindKeys '.editor', 'meta-G': 'command-panel:repeat-relative-address-in-reverse' 'meta-e': 'command-panel:set-selection-as-regex-address' 'meta-f': 'command-panel:find-in-file' + 'meta-F': 'command-panel:find-in-project' diff --git a/src/extensions/command-panel/operation.coffee b/src/extensions/command-panel/operation.coffee new file mode 100644 index 000000000..e2efc4f54 --- /dev/null +++ b/src/extensions/command-panel/operation.coffee @@ -0,0 +1,30 @@ +{$$$} = require 'space-pen' + +module.exports = +class Operation + constructor: ({@project, @buffer, bufferRange, @newText, @preserveSelection}) -> + @buffer.retain() + @anchorRange = @buffer.addAnchorRange(bufferRange) + + getPath: -> + @project.relativize(@buffer.getPath()) + + getBufferRange: -> + @anchorRange.getBufferRange() + + execute: (editSession) -> + @buffer.change(@getBufferRange(), @newText) if @newText + editSession.addSelectionForBufferRange(@getBufferRange()) unless @preserveSelection + + preview: -> + range = @anchorRange.getBufferRange() + line = @buffer.lineForRow(range.start.row) + prefix = line[0...range.start.column] + match = line[range.start.column...range.end.column] + suffix = line[range.end.column..] + + {prefix, suffix, match} + + destroy: -> + @buffer.release() + @anchorRange.destroy() \ No newline at end of file diff --git a/src/extensions/command-panel/preview-list.coffee b/src/extensions/command-panel/preview-list.coffee new file mode 100644 index 000000000..57828d7d3 --- /dev/null +++ b/src/extensions/command-panel/preview-list.coffee @@ -0,0 +1,82 @@ +$ = require 'jquery' +{$$$, View} = require 'space-pen' + +module.exports = +class PreviewList extends View + @content: -> + @ol class: 'preview-list', tabindex: -1, -> + + selectedOperationIndex: 0 + operations: null + + initialize: (@rootView) -> + @on 'move-down', => @selectNextOperation() + @on 'move-up', => @selectPreviousOperation() + @on 'command-panel:execute', => @executeSelectedOperation() + + @on 'mousedown', 'li', (e) => + @setSelectedOperationIndex(parseInt($(e.target).closest('li').data('index'))) + @executeSelectedOperation() + + destroy: -> + @destroyOperations() if @operations + + hasOperations: -> @operations? + + populate: (operations) -> + @destroyOperations() if @operations + @operations = operations + @empty() + @html $$$ -> + for operation, index in operations + {prefix, suffix, match} = operation.preview() + @li 'data-index': index, => + @span operation.getPath(), outlet: "path", class: "path" + @span outlet: "preview", class: "preview", => + @span prefix + @span match, class: 'match' + @span suffix + + @setSelectedOperationIndex(0) + @show() + + selectNextOperation: -> + @setSelectedOperationIndex(@selectedOperationIndex + 1) + + selectPreviousOperation: -> + @setSelectedOperationIndex(@selectedOperationIndex - 1) + + setSelectedOperationIndex: (index) -> + index = Math.max(0, index) + index = Math.min(@operations.length - 1, index) + @children(".selected").removeClass('selected') + element = @children("li:eq(#{index})") + element.addClass('selected') + @scrollToElement(element) + @selectedOperationIndex = index + + executeSelectedOperation: -> + operation = @getSelectedOperation() + editSession = @rootView.open(operation.getPath()) + operation.execute(editSession) + @rootView.focus() + false + + getOperations: -> + new Array(@operations...) + + destroyOperations: -> + operation.destroy() for operation in @getOperations() + @operations = null + + getSelectedOperation: -> + @operations[@selectedOperationIndex] + + scrollToElement: (element) -> + top = @scrollTop() + element.position().top + bottom = top + element.outerHeight() + + if bottom > @scrollBottom() + @scrollBottom(bottom) + if top < @scrollTop() + @scrollTop(top) diff --git a/src/extensions/snippets/snippets.coffee b/src/extensions/snippets/snippets.coffee index d1c51de9d..de88ed7a1 100644 --- a/src/extensions/snippets/snippets.coffee +++ b/src/extensions/snippets/snippets.coffee @@ -27,7 +27,7 @@ module.exports = editor.on 'snippets:expand', (e) => editSession = editor.activeEditSession prefix = editSession.getLastCursor().getCurrentWordPrefix() - if snippet = @snippetsByExtension[editSession.getFileExtension()][prefix] + if snippet = @snippetsByExtension[editSession.getFileExtension()]?[prefix] editSession.transact -> snippetExpansion = new SnippetExpansion(snippet, editSession) editSession.snippetExpansion = snippetExpansion diff --git a/src/stdlib/child-process.coffee b/src/stdlib/child-process.coffee index a724439e0..9bddda62e 100644 --- a/src/stdlib/child-process.coffee +++ b/src/stdlib/child-process.coffee @@ -1,50 +1,35 @@ # node.js child-process # http://nodejs.org/docs/v0.6.3/api/child_processes.html +$ = require 'jquery' _ = require 'underscore' module.exports = - exec: (command, options, callback) -> - callback = options if _.isFunction options +class ChildProccess + @exec: (command, options={}) -> + deferred = $.Deferred() - # make a task - task = OSX.NSTask.alloc.init + if options.bufferLines + options.stdout = @bufferLines(options.stdout) if options.stdout + options.stderr = @bufferLines(options.stderr) if options.stderr - # try to use their login shell - task.setLaunchPath "/bin/bash" + $native.exec command, options, (exitStatus, stdout, stderr) -> + try + if exitStatus != 0 + deferred.reject({command, exitStatus, stderr}) + else + deferred.resolve(stdout, stderr) + catch e + console.error "In ChildProccess termination callback: ", e.message + console.error e.stack - # set stdin to /dev/null - task.setStandardInput OSX.NSFileHandle.fileHandleWithNullDevice + deferred - # -l = login shell, -c = command - args = ["-l", "-c", command] - task.setArguments args - - # setup stdout and stderr - task.setStandardOutput stdout = OSX.NSPipe.pipe - task.setStandardError stderr = OSX.NSPipe.pipe - stdoutHandle = stdout.fileHandleForReading - stderrHandle = stderr.fileHandleForReading - - # begin - task.launch - - # read pipes - err = @readHandle stderrHandle - out = @readHandle stdoutHandle - - # check for a dirty exit - if not task.isRunning - code = task.terminationStatus - if code > 0 - error = new Error - error.code = code - - # call callback - callback error, out, err - - readHandle: (handle) -> - OSX.NSString. - alloc. - initWithData_encoding(handle.readDataToEndOfFile, OSX.NSUTF8StringEncoding). - toString() + @bufferLines: (callback) -> + buffered = "" + (data) -> + buffered += data + lastNewlineIndex = buffered.lastIndexOf('\n') + if lastNewlineIndex >= 0 + callback(buffered.substring(0, lastNewlineIndex + 1)) + buffered = buffered.substring(lastNewlineIndex + 1) diff --git a/src/stdlib/require.coffee b/src/stdlib/require.coffee index 15e72c976..186c79545 100644 --- a/src/stdlib/require.coffee +++ b/src/stdlib/require.coffee @@ -92,6 +92,7 @@ resolve = (file) -> return file __expand = (path) -> + return path if __isFile path for ext, handler of exts if __exists "#{path}.#{ext}" return "#{path}.#{ext}" @@ -104,6 +105,9 @@ __expand = (path) -> __exists = (path) -> $native.exists path +__isFile = (path) -> + $native.isFile path + __coffeeCache = (filePath) -> {CoffeeScript} = require 'coffee-script' tmpPath = "/tmp/atom-compiled-scripts" diff --git a/static/command-panel.css b/static/command-panel.css index 3b624029b..31801e14b 100644 --- a/static/command-panel.css +++ b/static/command-panel.css @@ -2,6 +2,44 @@ width: 100%; background: #515151; padding: 3px; +} + +.command-panel .preview-list { + max-height: 300px; + overflow: auto; + margin-bottom: 3px; + position: relative; + background: #161616; +} + +.command-panel .preview-list { + cursor: default; +} + +.command-panel .preview-list li.selected { + background: #444; +} + +.command-panel .preview-list:focus li.selected { + background: #223555; +} + +.command-panel .preview-list .path { + padding-left: 3px; + color: #f9ee98; + margin-right: 1ex; +} + +.command-panel .preview-list .preview { + color: #f6f3e8; +} + +.command-panel .preview-list .preview .match { + background-color: rgba(255,255,255,.25); + padding: 1px; +} + +.command-panel .prompt-and-editor { display: -webkit-box; } diff --git a/vendor/ack b/vendor/ack new file mode 100755 index 000000000..a20f9f603 --- /dev/null +++ b/vendor/ack @@ -0,0 +1,2784 @@ +#!/usr/bin/env perl +# +# This file, ack, is generated code. +# Please DO NOT EDIT or send patches for it. +# +# Please take a look at the source from +# http://github.com/petdance/ack +# and submit patches against the individual files +# that build ack. +# + +use warnings; +use strict; + +our $VERSION = '1.96'; +# Check http://betterthangrep.com/ for updates + +# These are all our globals. + + +MAIN: { + if ( $App::Ack::VERSION ne $main::VERSION ) { + App::Ack::die( "Program/library version mismatch\n\t$0 is $main::VERSION\n\t$INC{'App/Ack.pm'} is $App::Ack::VERSION" ); + } + + # Do preliminary arg checking; + my $env_is_usable = 1; + for ( @ARGV ) { + last if ( $_ eq '--' ); + + # Priorities! Get the --thpppt checking out of the way. + /^--th[pt]+t+$/ && App::Ack::_thpppt($_); + + # See if we want to ignore the environment. (Don't tell Al Gore.) + if ( /^--(no)?env$/ ) { + $env_is_usable = defined $1 ? 0 : 1; + } + } + if ( $env_is_usable ) { + unshift( @ARGV, App::Ack::read_ackrc() ); + } + else { + my @keys = ( 'ACKRC', grep { /^ACK_/ } keys %ENV ); + delete @ENV{@keys}; + } + App::Ack::load_colors(); + + if ( exists $ENV{ACK_SWITCHES} ) { + App::Ack::warn( 'ACK_SWITCHES is no longer supported. Use ACK_OPTIONS.' ); + } + + if ( !@ARGV ) { + App::Ack::show_help(); + exit 1; + } + + main(); +} + +sub main { + my $opt = App::Ack::get_command_line_options(); + + $| = 1 if $opt->{flush}; # Unbuffer the output if flush mode + + if ( App::Ack::input_from_pipe() ) { + # We're going into filter mode + for ( qw( f g l ) ) { + $opt->{$_} and App::Ack::die( "Can't use -$_ when acting as a filter." ); + } + $opt->{show_filename} = 0; + $opt->{regex} = App::Ack::build_regex( defined $opt->{regex} ? $opt->{regex} : shift @ARGV, $opt ); + if ( my $nargs = @ARGV ) { + my $s = $nargs == 1 ? '' : 's'; + App::Ack::warn( "Ignoring $nargs argument$s on the command-line while acting as a filter." ); + } + + my $res = App::Ack::Resource::Basic->new( '-' ); + my $nmatches; + if ( $opt->{count} ) { + $nmatches = App::Ack::search_and_list( $res, $opt ); + } + else { + # normal searching + $nmatches = App::Ack::search_resource( $res, $opt ); + } + $res->close(); + App::Ack::exit_from_ack( $nmatches ); + } + + my $file_matching = $opt->{f} || $opt->{lines}; + if ( $file_matching ) { + App::Ack::die( "Can't specify both a regex ($opt->{regex}) and use one of --line, -f or -g." ) if $opt->{regex}; + } + else { + $opt->{regex} = App::Ack::build_regex( defined $opt->{regex} ? $opt->{regex} : shift @ARGV, $opt ); + } + + # check that all regexes do compile fine + App::Ack::check_regex( $_ ) for ( $opt->{regex}, $opt->{G} ); + + my $what = App::Ack::get_starting_points( \@ARGV, $opt ); + my $iter = App::Ack::get_iterator( $what, $opt ); + App::Ack::filetype_setup(); + + my $nmatches = 0; + + App::Ack::set_up_pager( $opt->{pager} ) if defined $opt->{pager}; + if ( $opt->{f} ) { + $nmatches = App::Ack::print_files( $iter, $opt ); + } + elsif ( $opt->{l} || $opt->{count} ) { + $nmatches = App::Ack::print_files_with_matches( $iter, $opt ); + } + else { + $nmatches = App::Ack::print_matches( $iter, $opt ); + } + close $App::Ack::fh; + App::Ack::exit_from_ack( $nmatches ); +} + +=head1 NAME + +ack - grep-like text finder + +=head1 SYNOPSIS + + ack [options] PATTERN [FILE...] + ack -f [options] [DIRECTORY...] + +=head1 DESCRIPTION + +Ack is designed as a replacement for 99% of the uses of F. + +Ack searches the named input FILEs (or standard input if no files are +named, or the file name - is given) for lines containing a match to the +given PATTERN. By default, ack prints the matching lines. + +Ack can also list files that would be searched, without actually searching +them, to let you take advantage of ack's file-type filtering capabilities. + +=head1 FILE SELECTION + +I is intelligent about the files it searches. It knows about +certain file types, based on both the extension on the file and, +in some cases, the contents of the file. These selections can be +made with the B<--type> option. + +With no file selections, I only searches files of types that +it recognizes. If you have a file called F, and I +doesn't know what a .wango file is, I won't search it. + +The B<-a> option tells I to select all files, regardless of +type. + +Some files will never be selected by I, even with B<-a>, +including: + +=over 4 + +=item * Backup files: Files matching F<#*#> or ending with F<~>. + +=item * Coredumps: Files matching F + +=back + +However, I always searches the files given on the command line, +no matter what type. Furthermore, by specifying the B<-u> option all +files will be searched. + +=head1 DIRECTORY SELECTION + +I descends through the directory tree of the starting directories +specified. However, it will ignore the shadow directories used by +many version control systems, and the build directories used by the +Perl MakeMaker system. You may add or remove a directory from this +list with the B<--[no]ignore-dir> option. The option may be repeated +to add/remove multiple directories from the ignore list. + +For a complete list of directories that do not get searched, run +F. + +=head1 WHEN TO USE GREP + +I trumps I as an everyday tool 99% of the time, but don't +throw I away, because there are times you'll still need it. + +E.g., searching through huge files looking for regexes that can be +expressed with I syntax should be quicker with I. + +If your script or parent program uses I C<--quiet> or +C<--silent> or needs exit 2 on IO error, use I. + +=head1 OPTIONS + +=over 4 + +=item B<-a>, B<--all> + +Operate on all files, regardless of type (but still skip directories +like F, F, etc.) + +=item B<-A I>, B<--after-context=I> + +Print I lines of trailing context after matching lines. + +=item B<-B I>, B<--before-context=I> + +Print I lines of leading context before matching lines. + +=item B<-C [I]>, B<--context[=I]> + +Print I lines (default 2) of context around matching lines. + +=item B<-c>, B<--count> + +Suppress normal output; instead print a count of matching lines for +each input file. If B<-l> is in effect, it will only show the +number of lines for each file that has lines matching. Without +B<-l>, some line counts may be zeroes. + +If combined with B<-h> (B<--no-filename>) ack outputs only one total count. + +=item B<--color>, B<--nocolor> + +B<--color> highlights the matching text. B<--nocolor> supresses +the color. This is on by default unless the output is redirected. + +On Windows, this option is off by default unless the +L module is installed or the C +environment variable is used. + +=item B<--color-filename=I> + +Sets the color to be used for filenames. + +=item B<--color-match=I> + +Sets the color to be used for matches. + +=item B<--color-lineno=I> + +Sets the color to be used for line numbers. + +=item B<--column> + +Show the column number of the first match. This is helpful for editors +that can place your cursor at a given position. + +=item B<--env>, B<--noenv> + +B<--noenv> disables all environment processing. No F<.ackrc> is read +and all environment variables are ignored. By default, F considers +F<.ackrc> and settings in the environment. + +=item B<--flush> + +B<--flush> flushes output immediately. This is off by default +unless ack is running interactively (when output goes to a pipe +or file). + +=item B<-f> + +Only print the files that would be searched, without actually doing +any searching. PATTERN must not be specified, or it will be taken as +a path to search. + +=item B<--follow>, B<--nofollow> + +Follow or don't follow symlinks, other than whatever starting files +or directories were specified on the command line. + +This is off by default. + +=item B<-G I> + +Only paths matching I are included in the search. The entire +path and filename are matched against I, and I is a +Perl regular expression, not a shell glob. + +The options B<-i>, B<-w>, B<-v>, and B<-Q> do not apply to this I. + +=item B<-g I> + +Print files where the relative path + filename matches I. This option is +a convenience shortcut for B<-f> B<-G I>. + +The options B<-i>, B<-w>, B<-v>, and B<-Q> do not apply to this I. + +=item B<--group>, B<--nogroup> + +B<--group> groups matches by file name with. This is the default when +used interactively. + +B<--nogroup> prints one result per line, like grep. This is the default +when output is redirected. + +=item B<-H>, B<--with-filename> + +Print the filename for each match. + +=item B<-h>, B<--no-filename> + +Suppress the prefixing of filenames on output when multiple files are +searched. + +=item B<--help> + +Print a short help statement. + +=item B<-i>, B<--ignore-case> + +Ignore case in the search strings. + +This applies only to the PATTERN, not to the regexes given for the B<-g> +and B<-G> options. + +=item B<--[no]ignore-dir=I> + +Ignore directory (as CVS, .svn, etc are ignored). May be used multiple times +to ignore multiple directories. For example, mason users may wish to include +B<--ignore-dir=data>. The B<--noignore-dir> option allows users to search +directories which would normally be ignored (perhaps to research the contents +of F<.svn/props> directories). + +The I must always be a simple directory name. Nested directories like +F are NOT supported. You would need to specify B<--ignore-dir=foo> and +then no files from any foo directory are taken into account by ack unless given +explicitly on the command line. + +=item B<--line=I> + +Only print line I of each file. Multiple lines can be given with multiple +B<--line> options or as a comma separated list (B<--line=3,5,7>). B<--line=4-7> +also works. The lines are always output in ascending order, no matter the +order given on the command line. + +=item B<-l>, B<--files-with-matches> + +Only print the filenames of matching files, instead of the matching text. + +=item B<-L>, B<--files-without-matches> + +Only print the filenames of files that do I match. This is equivalent +to specifying B<-l> and B<-v>. + +=item B<--match I> + +Specify the I explicitly. This is helpful if you don't want to put the +regex as your first argument, e.g. when executing multiple searches over the +same set of files. + + # search for foo and bar in given files + ack file1 t/file* --match foo + ack file1 t/file* --match bar + +=item B<-m=I>, B<--max-count=I> + +Stop reading a file after I matches. + +=item B<--man> + +Print this manual page. + +=item B<-n>, B<--no-recurse> + +No descending into subdirectories. + +=item B<-o> + +Show only the part of each line matching PATTERN (turns off text +highlighting) + +=item B<--output=I> + +Output the evaluation of I for each line (turns off text +highlighting) + +=item B<--pager=I> + +Direct ack's output through I. This can also be specified +via the C and C environment variables. + +Using --pager does not suppress grouping and coloring like piping +output on the command-line does. + +=item B<--passthru> + +Prints all lines, whether or not they match the expression. Highlighting +will still work, though, so it can be used to highlight matches while +still seeing the entire file, as in: + + # Watch a log file, and highlight a certain IP address + $ tail -f ~/access.log | ack --passthru 123.45.67.89 + +=item B<--print0> + +Only works in conjunction with -f, -g, -l or -c (filename output). The filenames +are output separated with a null byte instead of the usual newline. This is +helpful when dealing with filenames that contain whitespace, e.g. + + # remove all files of type html + ack -f --html --print0 | xargs -0 rm -f + +=item B<-Q>, B<--literal> + +Quote all metacharacters in PATTERN, it is treated as a literal. + +This applies only to the PATTERN, not to the regexes given for the B<-g> +and B<-G> options. + +=item B<-r>, B<-R>, B<--recurse> + +Recurse into sub-directories. This is the default and just here for +compatibility with grep. You can also use it for turning B<--no-recurse> off. + +=item B<--smart-case>, B<--no-smart-case> + +Ignore case in the search strings if PATTERN contains no uppercase +characters. This is similar to C in vim. This option is +off by default. + +B<-i> always overrides this option. + +This applies only to the PATTERN, not to the regexes given for the +B<-g> and B<-G> options. + +=item B<--sort-files> + +Sorts the found files lexically. Use this if you want your file +listings to be deterministic between runs of I. + +=item B<--show-types> + +Outputs the filetypes that ack associates with each file. + +Works with B<-f> and B<-g> options. + +=item B<--thpppt> + +Display the all-important Bill The Cat logo. Note that the exact +spelling of B<--thpppppt> is not important. It's checked against +a regular expression. + +=item B<--type=TYPE>, B<--type=noTYPE> + +Specify the types of files to include or exclude from a search. +TYPE is a filetype, like I or I. B<--type=perl> can +also be specified as B<--perl>, and B<--type=noperl> can be done +as B<--noperl>. + +If a file is of both type "foo" and "bar", specifying --foo and +--nobar will exclude the file, because an exclusion takes precedence +over an inclusion. + +Type specifications can be repeated and are ORed together. + +See I for a list of valid types. + +=item B<--type-add I=I<.EXTENSION>[,I<.EXT2>[,...]]> + +Files with the given EXTENSION(s) are recognized as being of (the +existing) type TYPE. See also L. + + +=item B<--type-set I=I<.EXTENSION>[,I<.EXT2>[,...]]> + +Files with the given EXTENSION(s) are recognized as being of type +TYPE. This replaces an existing definition for type TYPE. See also +L. + +=item B<-u>, B<--unrestricted> + +All files and directories (including blib/, core.*, ...) are searched, +nothing is skipped. When both B<-u> and B<--ignore-dir> are used, the +B<--ignore-dir> option has no effect. + +=item B<-v>, B<--invert-match> + +Invert match: select non-matching lines + +This applies only to the PATTERN, not to the regexes given for the B<-g> +and B<-G> options. + +=item B<--version> + +Display version and copyright information. + +=item B<-w>, B<--word-regexp> + +Force PATTERN to match only whole words. The PATTERN is wrapped with +C<\b> metacharacters. + +This applies only to the PATTERN, not to the regexes given for the B<-g> +and B<-G> options. + +=item B<-1> + +Stops after reporting first match of any kind. This is different +from B<--max-count=1> or B<-m1>, where only one match per file is +shown. Also, B<-1> works with B<-f> and B<-g>, where B<-m> does +not. + +=back + +=head1 THE .ackrc FILE + +The F<.ackrc> file contains command-line options that are prepended +to the command line before processing. Multiple options may live +on multiple lines. Lines beginning with a # are ignored. A F<.ackrc> +might look like this: + + # Always sort the files + --sort-files + + # Always color, even if piping to a another program + --color + + # Use "less -r" as my pager + --pager=less -r + +Note that arguments with spaces in them do not need to be quoted, +as they are not interpreted by the shell. Basically, each I +in the F<.ackrc> file is interpreted as one element of C<@ARGV>. + +F looks in your home directory for the F<.ackrc>. You can +specify another location with the F variable, below. + +If B<--noenv> is specified on the command line, the F<.ackrc> file +is ignored. + +=head1 Defining your own types + +ack allows you to define your own types in addition to the predefined +types. This is done with command line options that are best put into +an F<.ackrc> file - then you do not have to define your types over and +over again. In the following examples the options will always be shown +on one command line so that they can be easily copy & pasted. + +I searches for foo in all perl files. I +tells you, that perl files are files ending +in .pl, .pm, .pod or .t. So what if you would like to include .xs +files as well when searching for --perl files? I +does this for you. B<--type-add> appends +additional extensions to an existing type. + +If you want to define a new type, or completely redefine an existing +type, then use B<--type-set>. I defines the type I to include files with +the extensions .e or .eiffel. So to search for all eiffel files +containing the word Bertrand use I. +As usual, you can also write B<--type=eiffel> +instead of B<--eiffel>. Negation also works, so B<--noeiffel> excludes +all eiffel files from a search. Redefining also works: I +and I<.xs> files no longer belong to the type I. + +When defining your own types in the F<.ackrc> file you have to use +the following: + + --type-set=eiffel=.e,.eiffel + +or writing on separate lines + + --type-set + eiffel=.e,.eiffel + +The following does B work in the F<.ackrc> file: + + --type-set eiffel=.e,.eiffel + + +In order to see all currently defined types, use I<--help types>, e.g. +I + +Restrictions: + +=over 4 + +=item + +The types 'skipped', 'make', 'binary' and 'text' are considered "builtin" and +cannot be altered. + +=item + +The shebang line recognition of the types 'perl', 'ruby', 'php', 'python', +'shell' and 'xml' cannot be redefined by I<--type-set>, it is always +active. However, the shebang line is only examined for files where the +extension is not recognised. Therefore it is possible to say +I and +only find your shiny new I<.perl> files (and all files with unrecognized extension +and perl on the shebang line). + +=back + +=head1 ENVIRONMENT VARIABLES + +For commonly-used ack options, environment variables can make life much easier. +These variables are ignored if B<--noenv> is specified on the command line. + +=over 4 + +=item ACKRC + +Specifies the location of the F<.ackrc> file. If this file doesn't +exist, F looks in the default location. + +=item ACK_OPTIONS + +This variable specifies default options to be placed in front of +any explicit options on the command line. + +=item ACK_COLOR_FILENAME + +Specifies the color of the filename when it's printed in B<--group> +mode. By default, it's "bold green". + +The recognized attributes are clear, reset, dark, bold, underline, +underscore, blink, reverse, concealed black, red, green, yellow, +blue, magenta, on_black, on_red, on_green, on_yellow, on_blue, +on_magenta, on_cyan, and on_white. Case is not significant. +Underline and underscore are equivalent, as are clear and reset. +The color alone sets the foreground color, and on_color sets the +background color. + +This option can also be set with B<--color-filename>. + +=item ACK_COLOR_MATCH + +Specifies the color of the matching text when printed in B<--color> +mode. By default, it's "black on_yellow". + +This option can also be set with B<--color-match>. + +See B for the color specifications. + +=item ACK_COLOR_LINENO + +Specifies the color of the line number when printed in B<--color> +mode. By default, it's "bold yellow". + +This option can also be set with B<--color-lineno>. + +See B for the color specifications. + +=item ACK_PAGER + +Specifies a pager program, such as C, C or C, to which +ack will send its output. + +Using C does not suppress grouping and coloring like +piping output on the command-line does, except that on Windows +ack will assume that C does not support color. + +C overrides C if both are specified. + +=item ACK_PAGER_COLOR + +Specifies a pager program that understands ANSI color sequences. +Using C does not suppress grouping and coloring +like piping output on the command-line does. + +If you are not on Windows, you never need to use C. + +=back + +=head1 ACK & OTHER TOOLS + +=head2 Vim integration + +F integrates easily with the Vim text editor. Set this in your +F<.vimrc> to use F instead of F: + + set grepprg=ack\ -a + +That examples uses C<-a> to search through all files, but you may +use other default flags. Now you can search with F and easily +step through the results in Vim: + + :grep Dumper perllib + +=head2 Emacs integration + +Phil Jackson put together an F extension that "provides a +simple compilation mode ... has the ability to guess what files you +want to search for based on the major-mode." + +L + +=head2 TextMate integration + +Pedro Melo is a TextMate user who writes "I spend my day mostly +inside TextMate, and the built-in find-in-project sucks with large +projects. So I hacked a TextMate command that was using find + +grep to use ack. The result is the Search in Project with ack, and +you can find it here: +L" + +=head2 Shell and Return Code + +For greater compatibility with I, I in normal use returns +shell return or exit code of 0 only if something is found and 1 if +no match is found. + +(Shell exit code 1 is C<$?=256> in perl with C or backticks.) + +The I code 2 for errors is not used. + +If C<-f> or C<-g> are specified, then 0 is returned if at least one +file is found. If no files are found, then 1 is returned. + +=cut + +=head1 DEBUGGING ACK PROBLEMS + +If ack gives you output you're not expecting, start with a few simple steps. + +=head2 Use B<--noenv> + +Your environment variables and F<.ackrc> may be doing things you're +not expecting, or forgotten you specified. Use B<--noenv> to ignore +your environment and F<.ackrc>. + +=head2 Use B<-f> to see what files you're scanning + +The reason I created B<-f> in the first place was as a debugging +tool. If ack is not finding matches you think it should find, run +F to see what files are being checked. + +=head1 TIPS + +=head2 Use the F<.ackrc> file. + +The F<.ackrc> is the place to put all your options you use most of +the time but don't want to remember. Put all your --type-add and +--type-set definitions in it. If you like --smart-case, set it +there, too. I also set --sort-files there. + +=head2 Use F<-f> for working with big codesets + +Ack does more than search files. C will create a +list of all the Perl files in a tree, ideal for sending into F. +For example: + + # Change all "this" to "that" in all Perl files in a tree. + ack -f --perl | xargs perl -p -i -e's/this/that/g' + +or if you prefer: + + perl -p -i -e's/this/thatg/' $(ack -f --perl) + +=head2 Use F<-Q> when in doubt about metacharacters + +If you're searching for something with a regular expression +metacharacter, most often a period in a filename or IP address, add +the -Q to avoid false positives without all the backslashing. See +the following example for more... + +=head2 Use ack to watch log files + +Here's one I used the other day to find trouble spots for a website +visitor. The user had a problem loading F, so I +took the access log and scanned it with ack twice. + + ack -Q aa.bb.cc.dd /path/to/access.log | ack -Q -B5 troublesome.gif + +The first ack finds only the lines in the Apache log for the given +IP. The second finds the match on my troublesome GIF, and shows +the previous five lines from the log in each case. + +=head2 Share your knowledge + +Join the ack-users mailing list. Send me your tips and I may add +them here. + +=head1 FAQ + +=head2 Why isn't ack finding a match in (some file)? + +Probably because it's of a type that ack doesn't recognize. ack's +searching behavior is driven by filetype. B + +Use the C<-f> switch to see a list of files that ack will search +for you. + +If you want ack to search files that it doesn't recognize, use the +C<-a> switch. + +If you want ack to search every file, even ones that it always +ignores like coredumps and backup files, use the C<-u> switch. + +=head2 Why does ack ignore unknown files by default? + +ack is designed by a programmer, for programmers, for searching +large trees of code. Most codebases have a lot files in them which +aren't source files (like compiled object files, source control +metadata, etc), and grep wastes a lot of time searching through all +of those as well and returning matches from those files. + +That's why ack's behavior of not searching things it doesn't recognize +is one of its greatest strengths: the speed you get from only +searching the things that you want to be looking at. + +=head2 Wouldn't it be great if F did search & replace? + +No, ack will always be read-only. Perl has a perfectly good way +to do search & replace in files, using the C<-i>, C<-p> and C<-n> +switches. + +You can certainly use ack to select your files to update. For +example, to change all "foo" to "bar" in all PHP files, you can do +this from the Unix shell: + + $ perl -i -p -e's/foo/bar/g' $(ack -f --php) + +=head2 Can you make ack recognize F<.xyz> files? + +That's an enhancement. Please see the section in the manual about +enhancements. + +=head2 There's already a program/package called ack. + +Yes, I know. + +=head2 Why is it called ack if it's called ack-grep? + +The name of the program is "ack". Some packagers have called it +"ack-grep" when creating packages because there's already a package +out there called "ack" that has nothing to do with this ack. + +I suggest you make a symlink named F that points to F +because one of the crucial benefits of ack is having a name that's +so short and simple to type. + +To do that, run this with F or as root: + + ln -s /usr/bin/ack-grep /usr/bin/ack + +=head2 What does F mean? + +Nothing. I wanted a name that was easy to type and that you could +pronounce as a single syllable. + +=head2 Can I do multi-line regexes? + +No, ack does not support regexes that match multiple lines. Doing +so would require reading in the entire file at a time. + +If you want to see lines near your match, use the C<--A>, C<--B> +and C<--C> switches for displaying context. + +=head1 AUTHOR + +Andy Lester, C<< >> + +=head1 BUGS + +Please report any bugs or feature requests to the issues list at +Github: L + +=head1 ENHANCEMENTS + +All enhancement requests MUST first be posted to the ack-users +mailing list at L. I +will not consider a request without it first getting seen by other +ack users. This includes requests for new filetypes. + +There is a list of enhancements I want to make to F in the ack +issues list at Github: L + +Patches are always welcome, but patches with tests get the most +attention. + +=head1 SUPPORT + +Support for and information about F can be found at: + +=over 4 + +=item * The ack homepage + +L + +=item * The ack issues list at Github + +L + +=item * AnnoCPAN: Annotated CPAN documentation + +L + +=item * CPAN Ratings + +L + +=item * Search CPAN + +L + +=item * Git source repository + +L + +=back + +=head1 ACKNOWLEDGEMENTS + +How appropriate to have Inowledgements! + +Thanks to everyone who has contributed to ack in any way, including +Matthew Wild, +Scott Kyle, +Nick Hooey, +Bo Borgerson, +Mark Szymanski, +Marq Schneider, +Packy Anderson, +JR Boyens, +Dan Sully, +Ryan Niebur, +Kent Fredric, +Mike Morearty, +Ingmar Vanhassel, +Eric Van Dewoestine, +Sitaram Chamarty, +Adam James, +Richard Carlsson, +Pedro Melo, +AJ Schuster, +Phil Jackson, +Michael Schwern, +Jan Dubois, +Christopher J. Madsen, +Matthew Wickline, +David Dyck, +Jason Porritt, +Jjgod Jiang, +Thomas Klausner, +Uri Guttman, +Peter Lewis, +Kevin Riggle, +Ori Avtalion, +Torsten Blix, +Nigel Metheringham, +GEbor SzabE, +Tod Hagan, +Michael Hendricks, +Evar ArnfjErE Bjarmason, +Piers Cawley, +Stephen Steneker, +Elias Lutfallah, +Mark Leighton Fisher, +Matt Diephouse, +Christian Jaeger, +Bill Sully, +Bill Ricker, +David Golden, +Nilson Santos F. Jr, +Elliot Shank, +Merijn Broeren, +Uwe Voelker, +Rick Scott, +Ask BjErn Hansen, +Jerry Gay, +Will Coleda, +Mike O'Regan, +Slaven ReziE<0x107>, +Mark Stosberg, +David Alan Pisoni, +Adriano Ferreira, +James Keenan, +Leland Johnson, +Ricardo Signes +and Pete Krawczyk. + +=head1 COPYRIGHT & LICENSE + +Copyright 2005-2011 Andy Lester. + +This program is free software; you can redistribute it and/or modify +it under the terms of the Artistic License v2.0. + +=cut +package File::Next; + +use strict; +use warnings; + + +our $VERSION = '1.06'; + + + +use File::Spec (); + + +our $name; # name of the current file +our $dir; # dir of the current file + +our %files_defaults; +our %skip_dirs; + +BEGIN { + %files_defaults = ( + file_filter => undef, + descend_filter => undef, + error_handler => sub { CORE::die @_ }, + sort_files => undef, + follow_symlinks => 1, + ); + %skip_dirs = map {($_,1)} (File::Spec->curdir, File::Spec->updir); +} + + +sub files { + ($_[0] eq __PACKAGE__) && die 'File::Next::files must not be invoked as File::Next->files'; + + my ($parms,@queue) = _setup( \%files_defaults, @_ ); + my $filter = $parms->{file_filter}; + + return sub { + while (@queue) { + my ($dir,$file,$fullpath) = splice( @queue, 0, 3 ); + if ( -f $fullpath ) { + if ( $filter ) { + local $_ = $file; + local $File::Next::dir = $dir; + local $File::Next::name = $fullpath; + next if not $filter->(); + } + return wantarray ? ($dir,$file,$fullpath) : $fullpath; + } + elsif ( -d _ ) { + unshift( @queue, _candidate_files( $parms, $fullpath ) ); + } + } # while + + return; + }; # iterator +} + + + + + + + +sub sort_standard($$) { return $_[0]->[1] cmp $_[1]->[1] } +sub sort_reverse($$) { return $_[1]->[1] cmp $_[0]->[1] } + +sub reslash { + my $path = shift; + + my @parts = split( /\//, $path ); + + return $path if @parts < 2; + + return File::Spec->catfile( @parts ); +} + + + +sub _setup { + my $defaults = shift; + my $passed_parms = ref $_[0] eq 'HASH' ? {%{+shift}} : {}; # copy parm hash + + my %passed_parms = %{$passed_parms}; + + my $parms = {}; + for my $key ( keys %{$defaults} ) { + $parms->{$key} = + exists $passed_parms{$key} + ? delete $passed_parms{$key} + : $defaults->{$key}; + } + + # Any leftover keys are bogus + for my $badkey ( keys %passed_parms ) { + my $sub = (caller(1))[3]; + $parms->{error_handler}->( "Invalid option passed to $sub(): $badkey" ); + } + + # If it's not a code ref, assume standard sort + if ( $parms->{sort_files} && ( ref($parms->{sort_files}) ne 'CODE' ) ) { + $parms->{sort_files} = \&sort_standard; + } + my @queue; + + for ( @_ ) { + my $start = reslash( $_ ); + if (-d $start) { + push @queue, ($start,undef,$start); + } + else { + push @queue, (undef,$start,$start); + } + } + + return ($parms,@queue); +} + + +sub _candidate_files { + my $parms = shift; + my $dir = shift; + + my $dh; + if ( !opendir $dh, $dir ) { + $parms->{error_handler}->( "$dir: $!" ); + return; + } + + my @newfiles; + my $descend_filter = $parms->{descend_filter}; + my $follow_symlinks = $parms->{follow_symlinks}; + my $sort_sub = $parms->{sort_files}; + + for my $file ( grep { !exists $skip_dirs{$_} } readdir $dh ) { + my $has_stat; + + # Only do directory checking if we have a descend_filter + my $fullpath = File::Spec->catdir( $dir, $file ); + if ( !$follow_symlinks ) { + next if -l $fullpath; + $has_stat = 1; + } + + if ( $descend_filter ) { + if ( $has_stat ? (-d _) : (-d $fullpath) ) { + local $File::Next::dir = $fullpath; + local $_ = $file; + next if not $descend_filter->(); + } + } + if ( $sort_sub ) { + push( @newfiles, [ $dir, $file, $fullpath ] ); + } + else { + push( @newfiles, $dir, $file, $fullpath ); + } + } + closedir $dh; + + if ( $sort_sub ) { + return map { @{$_} } sort $sort_sub @newfiles; + } + + return @newfiles; +} + + +1; # End of File::Next +package App::Ack; + +use warnings; +use strict; + + + + +our $VERSION; +our $COPYRIGHT; +BEGIN { + $VERSION = '1.96'; + $COPYRIGHT = 'Copyright 2005-2011 Andy Lester.'; +} + +our $fh; + +BEGIN { + $fh = *STDOUT; +} + + +our %types; +our %type_wanted; +our %mappings; +our %ignore_dirs; + +our $input_from_pipe; +our $output_to_pipe; + +our $dir_sep_chars; +our $is_cygwin; +our $is_windows; + +use File::Spec (); +use File::Glob ':glob'; +use Getopt::Long (); + +BEGIN { + %ignore_dirs = ( + '.bzr' => 'Bazaar', + '.cdv' => 'Codeville', + '~.dep' => 'Interface Builder', + '~.dot' => 'Interface Builder', + '~.nib' => 'Interface Builder', + '~.plst' => 'Interface Builder', + '.git' => 'Git', + '.hg' => 'Mercurial', + '.pc' => 'quilt', + '.svn' => 'Subversion', + _MTN => 'Monotone', + blib => 'Perl module building', + CVS => 'CVS', + RCS => 'RCS', + SCCS => 'SCCS', + _darcs => 'darcs', + _sgbak => 'Vault/Fortress', + 'autom4te.cache' => 'autoconf', + 'cover_db' => 'Devel::Cover', + _build => 'Module::Build', + ); + + %mappings = ( + actionscript => [qw( as mxml )], + ada => [qw( ada adb ads )], + asm => [qw( asm s )], + batch => [qw( bat cmd )], + binary => q{Binary files, as defined by Perl's -B op (default: off)}, + cc => [qw( c h xs )], + cfmx => [qw( cfc cfm cfml )], + clojure => [qw( clj )], + cpp => [qw( cpp cc cxx m hpp hh h hxx )], + csharp => [qw( cs )], + css => [qw( css )], + delphi => [qw( pas int dfm nfm dof dpk dproj groupproj bdsgroup bdsproj )], + elisp => [qw( el )], + erlang => [qw( erl hrl )], + fortran => [qw( f f77 f90 f95 f03 for ftn fpp )], + go => [qw( go )], + groovy => [qw( groovy gtmpl gpp grunit )], + haskell => [qw( hs lhs )], + hh => [qw( h )], + html => [qw( htm html shtml xhtml )], + java => [qw( java properties )], + js => [qw( js )], + jsp => [qw( jsp jspx jhtm jhtml )], + lisp => [qw( lisp lsp )], + lua => [qw( lua )], + make => q{Makefiles (including *.mk and *.mak)}, + mason => [qw( mas mhtml mpl mtxt )], + objc => [qw( m h )], + objcpp => [qw( mm h )], + ocaml => [qw( ml mli )], + parrot => [qw( pir pasm pmc ops pod pg tg )], + perl => [qw( pl pm pm6 pod t )], + php => [qw( php phpt php3 php4 php5 phtml)], + plone => [qw( pt cpt metadata cpy py )], + python => [qw( py )], + rake => q{Rakefiles}, + ruby => [qw( rb rhtml rjs rxml erb rake spec )], + scala => [qw( scala )], + scheme => [qw( scm ss )], + shell => [qw( sh bash csh tcsh ksh zsh )], + skipped => q{Files, but not directories, normally skipped by ack (default: off)}, + smalltalk => [qw( st )], + sql => [qw( sql ctl )], + tcl => [qw( tcl itcl itk )], + tex => [qw( tex cls sty )], + text => q{Text files, as defined by Perl's -T op (default: off)}, + tt => [qw( tt tt2 ttml )], + vb => [qw( bas cls frm ctl vb resx )], + verilog => [qw( v vh sv )], + vhdl => [qw( vhd vhdl )], + vim => [qw( vim )], + yaml => [qw( yaml yml )], + xml => [qw( xml dtd xsl xslt ent )], + ); + + while ( my ($type,$exts) = each %mappings ) { + if ( ref $exts ) { + for my $ext ( @{$exts} ) { + push( @{$types{$ext}}, $type ); + } + } + } + # add manually Makefile extensions + push @{$types{$_}}, 'make' for qw{ mk mak }; + + # These have to be checked before any filehandle diddling. + $output_to_pipe = not -t *STDOUT; + $input_from_pipe = -p STDIN; + + $is_cygwin = ($^O eq 'cygwin'); + $is_windows = ($^O =~ /MSWin32/); + $dir_sep_chars = $is_windows ? quotemeta( '\\/' ) : quotemeta( File::Spec->catfile( '', '' ) ); +} + + +sub read_ackrc { + my @files = ( $ENV{ACKRC} ); + my @dirs = + $is_windows + ? ( $ENV{HOME}, $ENV{USERPROFILE} ) + : ( '~', $ENV{HOME} ); + for my $dir ( grep { defined } @dirs ) { + for my $file ( '.ackrc', '_ackrc' ) { + push( @files, bsd_glob( "$dir/$file", GLOB_TILDE ) ); + } + } + for my $filename ( @files ) { + if ( defined $filename && -e $filename ) { + open( my $fh, '<', $filename ) or App::Ack::die( "$filename: $!\n" ); + my @lines = grep { /./ && !/^\s*#/ } <$fh>; + chomp @lines; + close $fh or App::Ack::die( "$filename: $!\n" ); + + # get rid of leading and trailing whitespaces + for ( @lines ) { + s/^\s+//; + s/\s+$//; + } + + return @lines; + } + } + + return; +} + + +sub get_command_line_options { + my %opt = ( + pager => $ENV{ACK_PAGER_COLOR} || $ENV{ACK_PAGER}, + ); + + my $getopt_specs = { + 1 => sub { $opt{1} = $opt{m} = 1 }, + 'A|after-context=i' => \$opt{after_context}, + 'B|before-context=i' => \$opt{before_context}, + 'C|context:i' => sub { shift; my $val = shift; $opt{before_context} = $opt{after_context} = ($val || 2) }, + 'a|all-types' => \$opt{all}, + 'break!' => \$opt{break}, + c => \$opt{count}, + 'color|colour!' => \$opt{color}, + 'color-match=s' => \$ENV{ACK_COLOR_MATCH}, + 'color-filename=s' => \$ENV{ACK_COLOR_FILENAME}, + 'color-lineno=s' => \$ENV{ACK_COLOR_LINENO}, + 'column!' => \$opt{column}, + count => \$opt{count}, + 'env!' => sub { }, # ignore this option, it is handled beforehand + f => \$opt{f}, + flush => \$opt{flush}, + 'follow!' => \$opt{follow}, + 'g=s' => sub { shift; $opt{G} = shift; $opt{f} = 1 }, + 'G=s' => \$opt{G}, + 'group!' => sub { shift; $opt{heading} = $opt{break} = shift }, + 'heading!' => \$opt{heading}, + 'h|no-filename' => \$opt{h}, + 'H|with-filename' => \$opt{H}, + 'i|ignore-case' => \$opt{i}, + 'invert-file-match' => \$opt{invert_file_match}, + 'lines=s' => sub { shift; my $val = shift; push @{$opt{lines}}, $val }, + 'l|files-with-matches' => \$opt{l}, + 'L|files-without-matches' => sub { $opt{l} = $opt{v} = 1 }, + 'm|max-count=i' => \$opt{m}, + 'match=s' => \$opt{regex}, + 'n|no-recurse' => \$opt{n}, + o => sub { $opt{output} = '$&' }, + 'output=s' => \$opt{output}, + 'pager=s' => \$opt{pager}, + 'nopager' => sub { $opt{pager} = undef }, + 'passthru' => \$opt{passthru}, + 'print0' => \$opt{print0}, + 'Q|literal' => \$opt{Q}, + 'r|R|recurse' => sub { $opt{n} = 0 }, + 'show-types' => \$opt{show_types}, + 'smart-case!' => \$opt{smart_case}, + 'sort-files' => \$opt{sort_files}, + 'u|unrestricted' => \$opt{u}, + 'v|invert-match' => \$opt{v}, + 'w|word-regexp' => \$opt{w}, + + 'ignore-dirs=s' => sub { shift; my $dir = remove_dir_sep( shift ); $ignore_dirs{$dir} = '--ignore-dirs' }, + 'noignore-dirs=s' => sub { shift; my $dir = remove_dir_sep( shift ); delete $ignore_dirs{$dir} }, + + 'version' => sub { print_version_statement(); exit; }, + 'help|?:s' => sub { shift; show_help(@_); exit; }, + 'help-types'=> sub { show_help_types(); exit; }, + 'man' => sub { + require Pod::Usage; + Pod::Usage::pod2usage({ + -verbose => 2, + -exitval => 0, + }); + }, + + 'type=s' => sub { + # Whatever --type=xxx they specify, set it manually in the hash + my $dummy = shift; + my $type = shift; + my $wanted = ($type =~ s/^no//) ? 0 : 1; # must not be undef later + + if ( exists $type_wanted{ $type } ) { + $type_wanted{ $type } = $wanted; + } + else { + App::Ack::die( qq{Unknown --type "$type"} ); + } + }, # type sub + }; + + # Stick any default switches at the beginning, so they can be overridden + # by the command line switches. + unshift @ARGV, split( ' ', $ENV{ACK_OPTIONS} ) if defined $ENV{ACK_OPTIONS}; + + # first pass through options, looking for type definitions + def_types_from_ARGV(); + + for my $i ( filetypes_supported() ) { + $getopt_specs->{ "$i!" } = \$type_wanted{ $i }; + } + + + my $parser = Getopt::Long::Parser->new(); + $parser->configure( 'bundling', 'no_ignore_case', ); + $parser->getoptions( %{$getopt_specs} ) or + App::Ack::die( 'See ack --help, ack --help-types or ack --man for options.' ); + + my $to_screen = not output_to_pipe(); + my %defaults = ( + all => 0, + color => $to_screen, + follow => 0, + break => $to_screen, + heading => $to_screen, + before_context => 0, + after_context => 0, + ); + if ( $is_windows && $defaults{color} && not $ENV{ACK_PAGER_COLOR} ) { + if ( $ENV{ACK_PAGER} || not eval { require Win32::Console::ANSI } ) { + $defaults{color} = 0; + } + } + if ( $to_screen && $ENV{ACK_PAGER_COLOR} ) { + $defaults{color} = 1; + } + + while ( my ($key,$value) = each %defaults ) { + if ( not defined $opt{$key} ) { + $opt{$key} = $value; + } + } + + if ( defined $opt{m} && $opt{m} <= 0 ) { + App::Ack::die( '-m must be greater than zero' ); + } + + for ( qw( before_context after_context ) ) { + if ( defined $opt{$_} && $opt{$_} < 0 ) { + App::Ack::die( "--$_ may not be negative" ); + } + } + + if ( defined( my $val = $opt{output} ) ) { + $opt{output} = eval qq[ sub { "$val" } ]; + } + if ( defined( my $l = $opt{lines} ) ) { + # --line=1 --line=5 is equivalent to --line=1,5 + my @lines = split( /,/, join( ',', @{$l} ) ); + + # --line=1-3 is equivalent to --line=1,2,3 + @lines = map { + my @ret; + if ( /-/ ) { + my ($from, $to) = split /-/, $_; + if ( $from > $to ) { + App::Ack::warn( "ignoring --line=$from-$to" ); + @ret = (); + } + else { + @ret = ( $from .. $to ); + } + } + else { + @ret = ( $_ ); + }; + @ret + } @lines; + + if ( @lines ) { + my %uniq; + @uniq{ @lines } = (); + $opt{lines} = [ sort { $a <=> $b } keys %uniq ]; # numerical sort and each line occurs only once! + } + else { + # happens if there are only ignored --line directives + App::Ack::die( 'All --line options are invalid.' ); + } + } + + return \%opt; +} + + +sub def_types_from_ARGV { + my @typedef; + + my $parser = Getopt::Long::Parser->new(); + # pass_through => leave unrecognized command line arguments alone + # no_auto_abbrev => otherwise -c is expanded and not left alone + $parser->configure( 'no_ignore_case', 'pass_through', 'no_auto_abbrev' ); + $parser->getoptions( + 'type-set=s' => sub { shift; push @typedef, ['c', shift] }, + 'type-add=s' => sub { shift; push @typedef, ['a', shift] }, + ) or App::Ack::die( 'See ack --help or ack --man for options.' ); + + for my $td (@typedef) { + my ($type, $ext) = split /=/, $td->[1]; + + if ( $td->[0] eq 'c' ) { + # type-set + if ( exists $mappings{$type} ) { + # can't redefine types 'make', 'skipped', 'text' and 'binary' + App::Ack::die( qq{--type-set: Builtin type "$type" cannot be changed.} ) + if ref $mappings{$type} ne 'ARRAY'; + + delete_type($type); + } + } + else { + # type-add + + # can't append to types 'make', 'skipped', 'text' and 'binary' + App::Ack::die( qq{--type-add: Builtin type "$type" cannot be changed.} ) + if exists $mappings{$type} && ref $mappings{$type} ne 'ARRAY'; + + App::Ack::warn( qq{--type-add: Type "$type" does not exist, creating with "$ext" ...} ) + unless exists $mappings{$type}; + } + + my @exts = split /,/, $ext; + s/^\.// for @exts; + + if ( !exists $mappings{$type} || ref($mappings{$type}) eq 'ARRAY' ) { + push @{$mappings{$type}}, @exts; + for my $e ( @exts ) { + push @{$types{$e}}, $type; + } + } + else { + App::Ack::die( qq{Cannot append to type "$type".} ); + } + } + + return; +} + + +sub delete_type { + my $type = shift; + + App::Ack::die( qq{Internal error: Cannot delete builtin type "$type".} ) + unless ref $mappings{$type} eq 'ARRAY'; + + delete $mappings{$type}; + delete $type_wanted{$type}; + for my $ext ( keys %types ) { + $types{$ext} = [ grep { $_ ne $type } @{$types{$ext}} ]; + } +} + + +sub ignoredir_filter { + return !exists $ignore_dirs{$_} && !exists $ignore_dirs{$File::Next::dir}; +} + + +sub remove_dir_sep { + my $path = shift; + $path =~ s/[$dir_sep_chars]$//; + + return $path; +} + + +use constant TEXT => 'text'; + +sub filetypes { + my $filename = shift; + + my $basename = $filename; + $basename =~ s{.*[$dir_sep_chars]}{}; + + return 'skipped' unless is_searchable( $basename ); + + my $lc_basename = lc $basename; + return ('make',TEXT) if $lc_basename eq 'makefile' || $lc_basename eq 'gnumakefile'; + return ('rake','ruby',TEXT) if $lc_basename eq 'rakefile'; + + # If there's an extension, look it up + if ( $filename =~ m{\.([^\.$dir_sep_chars]+)$}o ) { + my $ref = $types{lc $1}; + return (@{$ref},TEXT) if $ref; + } + + # At this point, we can't tell from just the name. Now we have to + # open it and look inside. + + return unless -e $filename; + # From Elliot Shank: + # I can't see any reason that -r would fail on these-- the ACLs look + # fine, and no program has any of them open, so the busted Windows + # file locking model isn't getting in there. If I comment the if + # statement out, everything works fine + # So, for cygwin, don't bother trying to check for readability. + if ( !$is_cygwin ) { + if ( !-r $filename ) { + App::Ack::warn( "$filename: Permission denied" ); + return; + } + } + + return 'binary' if -B $filename; + + # If there's no extension, or we don't recognize it, check the shebang line + my $fh; + if ( !open( $fh, '<', $filename ) ) { + App::Ack::warn( "$filename: $!" ); + return; + } + my $header = <$fh>; + close $fh; + + if ( $header =~ /^#!/ ) { + return ($1,TEXT) if $header =~ /\b(ruby|lua|p(?:erl|hp|ython))-?(\d[\d.]*)?\b/; + return ('shell',TEXT) if $header =~ /\b(?:ba|t?c|k|z)?sh\b/; + } + else { + return ('xml',TEXT) if $header =~ /\Q{Q}; + if ( $opt->{w} ) { + $str = "\\b$str" if $str =~ /^\w/; + $str = "$str\\b" if $str =~ /\w$/; + } + + my $regex_is_lc = $str eq lc $str; + if ( $opt->{i} || ($opt->{smart_case} && $regex_is_lc) ) { + $str = "(?i)$str"; + } + + return $str; +} + + +sub check_regex { + my $regex = shift; + + return unless defined $regex; + + eval { qr/$regex/ }; + if ($@) { + (my $error = $@) =~ s/ at \S+ line \d+.*//; + chomp($error); + App::Ack::die( "Invalid regex '$regex':\n $error" ); + } + + return; +} + + + + +sub warn { + return CORE::warn( _my_program(), ': ', @_, "\n" ); +} + + +sub die { + return CORE::die( _my_program(), ': ', @_, "\n" ); +} + +sub _my_program { + require File::Basename; + return File::Basename::basename( $0 ); +} + + + +sub filetypes_supported { + return keys %mappings; +} + +sub _get_thpppt { + my $y = q{_ /|,\\'!.x',=(www)=, U }; + $y =~ tr/,x!w/\nOo_/; + return $y; +} + +sub _thpppt { + my $y = _get_thpppt(); + App::Ack::print( "$y ack $_[0]!\n" ); + exit 0; +} + +sub _key { + my $str = lc shift; + $str =~ s/[^a-z]//g; + + return $str; +} + + +sub show_help { + my $help_arg = shift || 0; + + return show_help_types() if $help_arg =~ /^types?/; + + my $ignore_dirs = _listify( sort { _key($a) cmp _key($b) } keys %ignore_dirs ); + + App::Ack::print( <<"END_OF_HELP" ); +Usage: ack [OPTION]... PATTERN [FILE] + +Search for PATTERN in each source file in the tree from cwd on down. +If [FILES] is specified, then only those files/directories are checked. +ack may also search STDIN, but only if no FILE are specified, or if +one of FILES is "-". + +Default switches may be specified in ACK_OPTIONS environment variable or +an .ackrc file. If you want no dependency on the environment, turn it +off with --noenv. + +Example: ack -i select + +Searching: + -i, --ignore-case Ignore case distinctions in PATTERN + --[no]smart-case Ignore case distinctions in PATTERN, + only if PATTERN contains no upper case + Ignored if -i is specified + -v, --invert-match Invert match: select non-matching lines + -w, --word-regexp Force PATTERN to match only whole words + -Q, --literal Quote all metacharacters; PATTERN is literal + +Search output: + --line=NUM Only print line(s) NUM of each file + -l, --files-with-matches + Only print filenames containing matches + -L, --files-without-matches + Only print filenames with no matches + -o Show only the part of a line matching PATTERN + (turns off text highlighting) + --passthru Print all lines, whether matching or not + --output=expr Output the evaluation of expr for each line + (turns off text highlighting) + --match PATTERN Specify PATTERN explicitly. + -m, --max-count=NUM Stop searching in each file after NUM matches + -1 Stop searching after one match of any kind + -H, --with-filename Print the filename for each match + -h, --no-filename Suppress the prefixing filename on output + -c, --count Show number of lines matching per file + --column Show the column number of the first match + + -A NUM, --after-context=NUM + Print NUM lines of trailing context after matching + lines. + -B NUM, --before-context=NUM + Print NUM lines of leading context before matching + lines. + -C [NUM], --context[=NUM] + Print NUM lines (default 2) of output context. + + --print0 Print null byte as separator between filenames, + only works with -f, -g, -l, -L or -c. + +File presentation: + --pager=COMMAND Pipes all ack output through COMMAND. For example, + --pager="less -R". Ignored if output is redirected. + --nopager Do not send output through a pager. Cancels any + setting in ~/.ackrc, ACK_PAGER or ACK_PAGER_COLOR. + --[no]heading Print a filename heading above each file's results. + (default: on when used interactively) + --[no]break Print a break between results from different files. + (default: on when used interactively) + --group Same as --heading --break + --nogroup Same as --noheading --nobreak + --[no]color Highlight the matching text (default: on unless + output is redirected, or on Windows) + --[no]colour Same as --[no]color + --color-filename=COLOR + --color-match=COLOR + --color-lineno=COLOR Set the color for filenames, matches, and line numbers. + --flush Flush output immediately, even when ack is used + non-interactively (when output goes to a pipe or + file). + +File finding: + -f Only print the files found, without searching. + The PATTERN must not be specified. + -g REGEX Same as -f, but only print files matching REGEX. + --sort-files Sort the found files lexically. + --invert-file-match Print/search handle files that do not match -g/-G. + --show-types Show which types each file has. + +File inclusion/exclusion: + -a, --all-types All file types searched; + Ignores CVS, .svn and other ignored directories + -u, --unrestricted All files and directories searched + --[no]ignore-dir=name Add/Remove directory from the list of ignored dirs + -r, -R, --recurse Recurse into subdirectories (ack's default behavior) + -n, --no-recurse No descending into subdirectories + -G REGEX Only search files that match REGEX + + --perl Include only Perl files. + --type=perl Include only Perl files. + --noperl Exclude Perl files. + --type=noperl Exclude Perl files. + See "ack --help type" for supported filetypes. + + --type-set TYPE=.EXTENSION[,.EXT2[,...]] + Files with the given EXTENSION(s) are recognized as + being of type TYPE. This replaces an existing + definition for type TYPE. + --type-add TYPE=.EXTENSION[,.EXT2[,...]] + Files with the given EXTENSION(s) are recognized as + being of (the existing) type TYPE + + --[no]follow Follow symlinks. Default is off. + + Directories ignored by default: + $ignore_dirs + + Files not checked for type: + /~\$/ - Unix backup files + /#.+#\$/ - Emacs swap files + /[._].*\\.swp\$/ - Vi(m) swap files + /core\\.\\d+\$/ - core dumps + /[.-]min\\.js\$/ - Minified javascript files + +Miscellaneous: + --noenv Ignore environment variables and ~/.ackrc + --help This help + --man Man page + --version Display version & copyright + --thpppt Bill the Cat + +Exit status is 0 if match, 1 if no match. + +This is version $VERSION of ack. +END_OF_HELP + + return; + } + + + +sub show_help_types { + App::Ack::print( <<'END_OF_HELP' ); +Usage: ack [OPTION]... PATTERN [FILES] + +The following is the list of filetypes supported by ack. You can +specify a file type with the --type=TYPE format, or the --TYPE +format. For example, both --type=perl and --perl work. + +Note that some extensions may appear in multiple types. For example, +.pod files are both Perl and Parrot. + +END_OF_HELP + + my @types = filetypes_supported(); + my $maxlen = 0; + for ( @types ) { + $maxlen = length if $maxlen < length; + } + for my $type ( sort @types ) { + next if $type =~ /^-/; # Stuff to not show + my $ext_list = $mappings{$type}; + + if ( ref $ext_list ) { + $ext_list = join( ' ', map { ".$_" } @{$ext_list} ); + } + App::Ack::print( sprintf( " --[no]%-*.*s %s\n", $maxlen, $maxlen, $type, $ext_list ) ); + } + + return; +} + +sub _listify { + my @whats = @_; + + return '' if !@whats; + + my $end = pop @whats; + my $str = @whats ? join( ', ', @whats ) . " and $end" : $end; + + no warnings 'once'; + require Text::Wrap; + $Text::Wrap::columns = 75; + return Text::Wrap::wrap( '', ' ', $str ); +} + + +sub get_version_statement { + require Config; + + my $copyright = get_copyright(); + my $this_perl = $Config::Config{perlpath}; + if ($^O ne 'VMS') { + my $ext = $Config::Config{_exe}; + $this_perl .= $ext unless $this_perl =~ m/$ext$/i; + } + my $ver = sprintf( '%vd', $^V ); + + return <<"END_OF_VERSION"; +ack $VERSION +Running under Perl $ver at $this_perl + +$copyright + +This program is free software. You may modify or distribute it +under the terms of the Artistic License v2.0. +END_OF_VERSION +} + + +sub print_version_statement { + App::Ack::print( get_version_statement() ); + + return; +} + + +sub get_copyright { + return $COPYRIGHT; +} + + +sub load_colors { + eval 'use Term::ANSIColor ()'; + + $ENV{ACK_COLOR_MATCH} ||= 'black on_yellow'; + $ENV{ACK_COLOR_FILENAME} ||= 'bold green'; + $ENV{ACK_COLOR_LINENO} ||= 'bold yellow'; + + return; +} + + +sub is_interesting { + return if /^\./; + + my $include; + + for my $type ( filetypes( $File::Next::name ) ) { + if ( defined $type_wanted{$type} ) { + if ( $type_wanted{$type} ) { + $include = 1; + } + else { + return; + } + } + } + + return $include; +} + + + +# print subs added in order to make it easy for a third party +# module (such as App::Wack) to redefine the display methods +# and show the results in a different way. +sub print { print {$fh} @_ } +sub print_first_filename { App::Ack::print( $_[0], "\n" ) } +sub print_blank_line { App::Ack::print( "\n" ) } +sub print_separator { App::Ack::print( "--\n" ) } +sub print_filename { App::Ack::print( $_[0], $_[1] ) } +sub print_line_no { App::Ack::print( $_[0], $_[1] ) } +sub print_column_no { App::Ack::print( $_[0], $_[1] ) } +sub print_count { + my $filename = shift; + my $nmatches = shift; + my $ors = shift; + my $count = shift; + my $show_filename = shift; + + if ($show_filename) { + App::Ack::print( $filename ); + App::Ack::print( ':', $nmatches ) if $count; + } + else { + App::Ack::print( $nmatches ) if $count; + } + App::Ack::print( $ors ); +} + +sub print_count0 { + my $filename = shift; + my $ors = shift; + my $show_filename = shift; + + if ($show_filename) { + App::Ack::print( $filename, ':0', $ors ); + } + else { + App::Ack::print( '0', $ors ); + } +} + + + +{ + my $filename; + my $regex; + my $display_filename; + + my $keep_context; + + my $last_output_line; # number of the last line that has been output + my $any_output; # has there been any output for the current file yet + my $context_overall_output_count; # has there been any output at all + +sub search_resource { + my $res = shift; + my $opt = shift; + + $filename = $res->name(); + + my $v = $opt->{v}; + my $passthru = $opt->{passthru}; + my $max = $opt->{m}; + my $nmatches = 0; + + $display_filename = undef; + + # for --line processing + my $has_lines = 0; + my @lines; + if ( defined $opt->{lines} ) { + $has_lines = 1; + @lines = ( @{$opt->{lines}}, -1 ); + undef $regex; # Don't match when printing matching line + } + else { + $regex = qr/$opt->{regex}/; + } + + # for context processing + $last_output_line = -1; + $any_output = 0; + my $before_context = $opt->{before_context}; + my $after_context = $opt->{after_context}; + + $keep_context = ($before_context || $after_context) && !$passthru; + + my @before; + my $before_starts_at_line; + my $after = 0; # number of lines still to print after a match + + while ( $res->next_text ) { + # XXX Optimize away the case when there are no more @lines to find. + # XXX $has_lines, $passthru and $v never change. Optimize. + if ( $has_lines + ? $. != $lines[0] # $lines[0] should be a scalar + : $v ? m/$regex/ : !m/$regex/ ) { + if ( $passthru ) { + App::Ack::print( $_ ); + next; + } + + if ( $keep_context ) { + if ( $after ) { + print_match_or_context( $opt, 0, $., $-[0], $+[0], $_ ); + $after--; + } + elsif ( $before_context ) { + if ( @before ) { + if ( @before >= $before_context ) { + shift @before; + ++$before_starts_at_line; + } + } + else { + $before_starts_at_line = $.; + } + push @before, $_; + } + last if $max && ( $nmatches >= $max ) && !$after; + } + next; + } # not a match + + ++$nmatches; + + # print an empty line as a divider before first line in each file (not before the first file) + if ( !$any_output && $opt->{show_filename} && $opt->{break} && defined( $context_overall_output_count ) ) { + App::Ack::print_blank_line(); + } + + shift @lines if $has_lines; + + if ( $res->is_binary ) { + App::Ack::print( "Binary file $filename matches\n" ); + last; + } + if ( $keep_context ) { + if ( @before ) { + print_match_or_context( $opt, 0, $before_starts_at_line, $-[0], $+[0], @before ); + @before = (); + $before_starts_at_line = 0; + } + if ( $max && $nmatches > $max ) { + --$after; + } + else { + $after = $after_context; + } + } + print_match_or_context( $opt, 1, $., $-[0], $+[0], $_ ); + + last if $max && ( $nmatches >= $max ) && !$after; + } # while + + return $nmatches; +} # search_resource() + + + +sub print_match_or_context { + my $opt = shift; # opts array + my $is_match = shift; # is there a match on the line? + my $line_no = shift; + my $match_start = shift; + my $match_end = shift; + + my $color = $opt->{color}; + my $heading = $opt->{heading}; + my $show_filename = $opt->{show_filename}; + my $show_column = $opt->{column}; + + if ( $show_filename ) { + if ( not defined $display_filename ) { + $display_filename = + $color + ? Term::ANSIColor::colored( $filename, $ENV{ACK_COLOR_FILENAME} ) + : $filename; + if ( $heading && !$any_output ) { + App::Ack::print_first_filename($display_filename); + } + } + } + + # Modified for Atom by Nathan Sobo to print a "\0" instead of a ":" as a separator + my $sep = $is_match ? "\0" : '-'; + my $output_func = $opt->{output}; + for ( @_ ) { + if ( $keep_context && !$output_func ) { + if ( ( $last_output_line != $line_no - 1 ) && + ( $any_output || ( !$heading && defined( $context_overall_output_count ) ) ) ) { + App::Ack::print_separator(); + } + # to ensure separators between different files when --noheading + + $last_output_line = $line_no; + } + + if ( $show_filename ) { + App::Ack::print_filename($display_filename, $sep) if not $heading; + my $display_line_no = + $color + ? Term::ANSIColor::colored( $line_no, $ENV{ACK_COLOR_LINENO} ) + : $line_no; + App::Ack::print_line_no($display_line_no, $sep); + } + + if ( $output_func ) { + while ( /$regex/go ) { + App::Ack::print( $output_func->() . "\n" ); + } + } + else { + if ( $color && $is_match && $regex && + s/$regex/Term::ANSIColor::colored( substr($_, $-[0], $+[0] - $-[0]), $ENV{ACK_COLOR_MATCH} )/eg ) { + # At the end of the line reset the color and remove newline + s/[\r\n]*\z/\e[0m\e[K/; + } + else { + # remove any kind of newline at the end of the line + s/[\r\n]*\z//; + } + if ( $show_column ) { + App::Ack::print_column_no( $match_start+1, $sep ); + } + App::Ack::print($_ . "\n"); + } + $any_output = 1; + ++$context_overall_output_count; + ++$line_no; + } + + return; +} # print_match_or_context() + +} # scope around search_resource() and print_match_or_context() + + +TOTAL_COUNT_SCOPE: { +my $total_count; + +sub get_total_count { + return $total_count; +} + +sub reset_total_count { + $total_count = 0; +} + + +sub search_and_list { + my $res = shift; + my $opt = shift; + + my $nmatches = 0; + my $count = $opt->{count}; + my $ors = $opt->{print0} ? "\0" : "\n"; # output record separator + my $show_filename = $opt->{show_filename}; + + my $regex = qr/$opt->{regex}/; + + if ( $opt->{v} ) { + while ( $res->next_text ) { + if ( /$regex/ ) { + return 0 unless $count; + } + else { + ++$nmatches; + } + } + } + else { + while ( $res->next_text ) { + if ( /$regex/ ) { + ++$nmatches; + last unless $count; + } + } + } + + if ( $opt->{show_total} ) { + $total_count += $nmatches; + } + else { + if ( $nmatches ) { + App::Ack::print_count( $res->name, $nmatches, $ors, $count, $show_filename ); + } + elsif ( $count && !$opt->{l} ) { + App::Ack::print_count0( $res->name, $ors, $show_filename ); + } + } + + return $nmatches ? 1 : 0; +} # search_and_list() + +} # scope around $total_count + + + +sub filetypes_supported_set { + return grep { defined $type_wanted{$_} && ($type_wanted{$_} == 1) } filetypes_supported(); +} + + + +sub print_files { + my $iter = shift; + my $opt = shift; + + my $ors = $opt->{print0} ? "\0" : "\n"; + + my $nmatches = 0; + while ( defined ( my $file = $iter->() ) ) { + App::Ack::print $file, $opt->{show_types} ? " => " . join( ',', filetypes( $file ) ) : (), $ors; + $nmatches++; + last if $opt->{1}; + } + + return $nmatches; +} + + +sub print_files_with_matches { + my $iter = shift; + my $opt = shift; + + # if we have -l and only 1 file given on command line (this means + # show_filename is set to 0), we want to see the filename nevertheless + $opt->{show_filename} = 1 if $opt->{l}; + + $opt->{show_filename} = 0 if $opt->{h}; + $opt->{show_filename} = 1 if $opt->{H}; + + # abuse options to hand in the show_total parameter to search_and_list + $opt->{show_total} = $opt->{count} && !$opt->{show_filename}; + reset_total_count(); + + my $nmatches = 0; + while ( defined ( my $filename = $iter->() ) ) { + my $repo = App::Ack::Repository::Basic->new( $filename ); + my $res; + while ( $res = $repo->next_resource() ) { + $nmatches += search_and_list( $res, $opt ); + $res->close(); + last if $nmatches && $opt->{1}; + } + $repo->close(); + } + + if ( $nmatches && $opt->{show_total} ) { + App::Ack::print_count('', get_total_count(), "\n", 1, 0 ) + } + + return $nmatches; +} + + +sub print_matches { + my $iter = shift; + my $opt = shift; + + $opt->{show_filename} = 0 if $opt->{h}; + $opt->{show_filename} = 1 if $opt->{H}; + + my $nmatches = 0; + while ( defined ( my $filename = $iter->() ) ) { + my $repo; + my $tarballs_work = 0; + if ( $tarballs_work && $filename =~ /\.tar\.gz$/ ) { + App::Ack::die( 'Not working here yet' ); + require App::Ack::Repository::Tar; # XXX Error checking + $repo = App::Ack::Repository::Tar->new( $filename ); + } + else { + $repo = App::Ack::Repository::Basic->new( $filename ); + } + $repo or next; + + while ( my $res = $repo->next_resource() ) { + my $needs_line_scan; + if ( $opt->{regex} && !$opt->{passthru} ) { + $needs_line_scan = $res->needs_line_scan( $opt ); + if ( $needs_line_scan ) { + $res->reset(); + } + } + else { + $needs_line_scan = 1; + } + if ( $needs_line_scan ) { + $nmatches += search_resource( $res, $opt ); + } + $res->close(); + } + last if $nmatches && $opt->{1}; + $repo->close(); + } + return $nmatches; +} + + +sub filetype_setup { + my $filetypes_supported_set = filetypes_supported_set(); + # If anyone says --no-whatever, we assume all other types must be on. + if ( !$filetypes_supported_set ) { + for my $i ( keys %type_wanted ) { + $type_wanted{$i} = 1 unless ( defined( $type_wanted{$i} ) || $i eq 'binary' || $i eq 'text' || $i eq 'skipped' ); + } + } + return; +} + + +EXPAND_FILENAMES_SCOPE: { + my $filter; + + sub expand_filenames { + my $argv = shift; + + my $attr; + my @files; + + foreach my $pattern ( @{$argv} ) { + my @results = bsd_glob( $pattern ); + + if (@results == 0) { + @results = $pattern; # Glob didn't match, pass it thru unchanged + } + elsif ( (@results > 1) or ($results[0] ne $pattern) ) { + if (not defined $filter) { + eval 'require Win32::File;'; + if ($@) { + $filter = 0; + } + else { + $filter = Win32::File::HIDDEN()|Win32::File::SYSTEM(); + } + } # end unless we've tried to load Win32::File + if ( $filter ) { + # Filter out hidden and system files: + @results = grep { not(Win32::File::GetAttributes($_, $attr) and $attr & $filter) } @results; + App::Ack::warn( "$pattern: Matched only hidden files" ) unless @results; + } # end if we can filter by file attributes + } # end elsif this pattern got expanded + + push @files, @results; + } # end foreach pattern + + return \@files; + } # end expand_filenames +} # EXPAND_FILENAMES_SCOPE + + + +sub get_starting_points { + my $argv = shift; + my $opt = shift; + + my @what; + + if ( @{$argv} ) { + @what = @{ $is_windows ? expand_filenames($argv) : $argv }; + $_ = File::Next::reslash( $_ ) for @what; + + # Show filenames unless we've specified one single file + $opt->{show_filename} = (@what > 1) || (!-f $what[0]); + } + else { + @what = '.'; # Assume current directory + $opt->{show_filename} = 1; + } + + for my $start_point (@what) { + App::Ack::warn( "$start_point: No such file or directory" ) unless -e $start_point; + } + return \@what; +} + +sub _match { + my ( $target, $expression, $invert_flag ) = @_; + + if ( $invert_flag ) { + return $target !~ $expression; + } + else { + return $target =~ $expression; + } +} + + +sub get_iterator { + my $what = shift; + my $opt = shift; + + # Starting points are always searched, no matter what + my %starting_point = map { ($_ => 1) } @{$what}; + + my $g_regex = defined $opt->{G} ? qr/$opt->{G}/ : undef; + my $file_filter; + + if ( $g_regex ) { + $file_filter + = $opt->{u} ? sub { _match( $File::Next::name, qr/$g_regex/, $opt->{invert_file_match} ) } # XXX Maybe this should be a 1, no? + : $opt->{all} ? sub { $starting_point{ $File::Next::name } || ( _match( $File::Next::name, qr/$g_regex/, $opt->{invert_file_match} ) && is_searchable( $_ ) ) } + : sub { $starting_point{ $File::Next::name } || ( _match( $File::Next::name, qr/$g_regex/, $opt->{invert_file_match} ) && is_interesting( @ _) ) } + ; + } + else { + $file_filter + = $opt->{u} ? sub {1} + : $opt->{all} ? sub { $starting_point{ $File::Next::name } || is_searchable( $_ ) } + : sub { $starting_point{ $File::Next::name } || is_interesting( @_ ) } + ; + } + + my $descend_filter + = $opt->{n} ? sub {0} + : $opt->{u} ? sub {1} + : \&ignoredir_filter; + + my $iter = + File::Next::files( { + file_filter => $file_filter, + descend_filter => $descend_filter, + error_handler => sub { my $msg = shift; App::Ack::warn( $msg ) }, + sort_files => $opt->{sort_files}, + follow_symlinks => $opt->{follow}, + }, @{$what} ); + return $iter; +} + + +sub set_up_pager { + my $command = shift; + + return if App::Ack::output_to_pipe(); + + my $pager; + if ( not open( $pager, '|-', $command ) ) { + App::Ack::die( qq{Unable to pipe to pager "$command": $!} ); + } + $fh = $pager; + + return; +} + + +sub input_from_pipe { + return $input_from_pipe; +} + + + +sub output_to_pipe { + return $output_to_pipe; +} + + +sub exit_from_ack { + my $nmatches = shift; + + my $rc = $nmatches ? 0 : 1; + exit $rc; +} + + + +1; # End of App::Ack +package App::Ack::Repository; + + +use warnings; +use strict; + +sub FAIL { + require Carp; + Carp::confess( 'Must be overloaded' ); +} + + +sub new { + FAIL(); +} + + +sub next_resource { + FAIL(); +} + + +sub close { + FAIL(); +} + +1; +package App::Ack::Resource; + + +use warnings; +use strict; + +sub FAIL { + require Carp; + Carp::confess( 'Must be overloaded' ); +} + + +sub new { + FAIL(); +} + + +sub name { + FAIL(); +} + + +sub is_binary { + FAIL(); +} + + + +sub needs_line_scan { + FAIL(); +} + + +sub reset { + FAIL(); +} + + +sub next_text { + FAIL(); +} + + +sub close { + FAIL(); +} + +1; +package App::Ack::Plugin::Basic; + + + +package App::Ack::Resource::Basic; + + +use warnings; +use strict; + + +our @ISA = qw( App::Ack::Resource ); + + +sub new { + my $class = shift; + my $filename = shift; + + my $self = bless { + filename => $filename, + fh => undef, + could_be_binary => undef, + opened => undef, + id => undef, + }, $class; + + if ( $self->{filename} eq '-' ) { + $self->{fh} = *STDIN; + $self->{could_be_binary} = 0; + } + else { + if ( !open( $self->{fh}, '<', $self->{filename} ) ) { + App::Ack::warn( "$self->{filename}: $!" ); + return; + } + $self->{could_be_binary} = 1; + } + + return $self; +} + + +sub name { + my $self = shift; + + return $self->{filename}; +} + + +sub is_binary { + my $self = shift; + + if ( $self->{could_be_binary} ) { + return -B $self->{filename}; + } + + return 0; +} + + + +sub needs_line_scan { + my $self = shift; + my $opt = shift; + + return 1 if $opt->{v}; + + my $size = -s $self->{fh}; + if ( $size == 0 ) { + return 0; + } + elsif ( $size > 100_000 ) { + return 1; + } + + my $buffer; + my $rc = sysread( $self->{fh}, $buffer, $size ); + if ( not defined $rc ) { + App::Ack::warn( "$self->{filename}: $!" ); + return 1; + } + return 0 unless $rc && ( $rc == $size ); + + my $regex = $opt->{regex}; + return $buffer =~ /$regex/m; +} + + +sub reset { + my $self = shift; + + seek( $self->{fh}, 0, 0 ) + or App::Ack::warn( "$self->{filename}: $!" ); + + return; +} + + +sub next_text { + if ( defined ($_ = readline $_[0]->{fh}) ) { + $. = ++$_[0]->{line}; + return 1; + } + + return; +} + + +sub close { + my $self = shift; + + if ( not close $self->{fh} ) { + App::Ack::warn( $self->name() . ": $!" ); + } + + return; +} + +package App::Ack::Repository::Basic; + + +our @ISA = qw( App::Ack::Repository ); + + +use warnings; +use strict; + +sub new { + my $class = shift; + my $filename = shift; + + my $self = bless { + filename => $filename, + nexted => 0, + }, $class; + + return $self; +} + + +sub next_resource { + my $self = shift; + + return if $self->{nexted}; + $self->{nexted} = 1; + + return App::Ack::Resource::Basic->new( $self->{filename} ); +} + + +sub close { +} + + + +1; diff --git a/vendor/ag b/vendor/ag new file mode 100755 index 000000000..63e16c228 Binary files /dev/null and b/vendor/ag differ