diff --git a/native/v8_extensions/onig_scanner.mm b/native/v8_extensions/onig_scanner.mm index c2d7346cf..b357ffeca 100644 --- a/native/v8_extensions/onig_scanner.mm +++ b/native/v8_extensions/onig_scanner.mm @@ -57,10 +57,7 @@ class OnigScannerUserData : public CefBase { bool useCachedResult = false; OnigResult *result = NULL; - // In Oniguruma, \G is based on the start position of the match, so the result - // changes based on the start position. So it can't be cached. - BOOL containsBackslashG = [regExp.expression rangeOfString:@"\\G"].location != NSNotFound; - if (useCachedResults && index <= maxCachedIndex && ! containsBackslashG) { + if (useCachedResults && index <= maxCachedIndex) { result = cachedResults[index]; useCachedResult = (result == NULL || [result locationAt:0] >= startLocation); } @@ -158,4 +155,4 @@ bool OnigScanner::Execute(const CefString& name, return false; } -} // namespace v8_extensions \ No newline at end of file +} // namespace v8_extensions diff --git a/spec/app/editor-spec.coffee b/spec/app/editor-spec.coffee index 693e3b93e..1ee20fa91 100644 --- a/spec/app/editor-spec.coffee +++ b/spec/app/editor-spec.coffee @@ -1728,14 +1728,14 @@ describe "Editor", -> describe "when there is no wrapping", -> it "highlights the line where the initial cursor position is", -> expect(editor.getCursorBufferPosition().row).toBe 0 - expect(editor.find('.line-number.cursor-line').length).toBe 1 - expect(editor.find('.line-number.cursor-line').text()).toBe "1" + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').length).toBe 1 + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').text()).toBe "1" it "updates the highlighted line when the cursor position changes", -> editor.setCursorBufferPosition([1,0]) expect(editor.getCursorBufferPosition().row).toBe 1 - expect(editor.find('.line-number.cursor-line').length).toBe 1 - expect(editor.find('.line-number.cursor-line').text()).toBe "2" + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').length).toBe 1 + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').text()).toBe "2" describe "when there is wrapping", -> beforeEach -> @@ -1745,23 +1745,28 @@ describe "Editor", -> it "highlights the line where the initial cursor position is", -> expect(editor.getCursorBufferPosition().row).toBe 0 - expect(editor.find('.line-number.cursor-line').length).toBe 1 - expect(editor.find('.line-number.cursor-line').text()).toBe "1" + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').length).toBe 1 + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').text()).toBe "1" it "updates the highlighted line when the cursor position changes", -> editor.setCursorBufferPosition([1,0]) expect(editor.getCursorBufferPosition().row).toBe 1 - expect(editor.find('.line-number.cursor-line').length).toBe 1 - expect(editor.find('.line-number.cursor-line').text()).toBe "2" + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').length).toBe 1 + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').text()).toBe "2" describe "when the selection spans multiple lines", -> beforeEach -> editor.attachToDom(30) - it "doesn't highlight the background or the gutter", -> + it "highlights the foreground of the gutter", -> editor.getSelection().setBufferRange(new Range([0,0],[2,0])) expect(editor.getSelection().isSingleScreenLine()).toBe false - expect(editor.find('.line-number.cursor-line').length).toBe 0 + expect(editor.find('.line-number.cursor-line').length).toBe 3 + + it "doesn't highlight the background of the gutter", -> + editor.getSelection().setBufferRange(new Range([0,0],[2,0])) + expect(editor.getSelection().isSingleScreenLine()).toBe false + expect(editor.find('.line-number.cursor-line.cursor-line-no-selection').length).toBe 0 it "when a newline is deleted with backspace, the line number of the new cursor position is highlighted", -> editor.setCursorScreenPosition([1,0]) @@ -1985,3 +1990,15 @@ describe "Editor", -> runs -> expect(editor.getText()).toBe(originalPathText) + + describe "when clicking a gutter line", -> + it "moves the cursor to the start of the selected line", -> + rootView.attachToDom() + expect(editor.getCursorScreenPosition()).toEqual [0,0] + editor.gutter.find(".line-number:eq(1)").trigger 'click' + expect(editor.getCursorScreenPosition()).toEqual [1,0] + + it "selects to the start of the selected line when shift is pressed", -> + expect(editor.getSelection().getScreenRange()).toEqual [0,0], [0,0] + editor.gutter.find(".line-number:eq(1)").trigger 'click', {shiftKey: true} + expect(editor.getSelection().getScreenRange()).toEqual [0,0], [1,0] diff --git a/spec/app/tokenized-buffer-spec.coffee b/spec/app/tokenized-buffer-spec.coffee index ce5e37b76..2eb7b74fb 100644 --- a/spec/app/tokenized-buffer-spec.coffee +++ b/spec/app/tokenized-buffer-spec.coffee @@ -14,7 +14,7 @@ describe "TokenizedBuffer", -> fullyTokenize = (tokenizedBuffer) -> advanceClock() while tokenizedBuffer.firstInvalidRow()? - changeHandler.reset() + changeHandler?.reset() describe "when the buffer contains soft-tabs", -> beforeEach -> @@ -326,3 +326,60 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.lineForScreenRow(2).text).toBe "#{tabAsSpaces} buy()#{tabAsSpaces}while supply > demand" + describe "when a Git commit message file is tokenized", -> + beforeEach -> + editSession = fixturesProject.buildEditSessionForPath('COMMIT_EDITMSG', autoIndent: false) + buffer = editSession.buffer + tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer + editSession.setVisible(true) + fullyTokenize(tokenizedBuffer) + + afterEach -> + editSession.destroy() + + it "correctly parses a long line", -> + longLine = tokenizedBuffer.lineForScreenRow(0) + expect(longLine.text).toBe "longggggggggggggggggggggggggggggggggggggggggggggggg" + { tokens } = longLine + + expect(tokens[0].value).toBe "longggggggggggggggggggggggggggggggggggggggggggggggg" + expect(tokens[0].scopes).toEqual ["text.git-commit", "meta.scope.message.git-commit", "invalid.deprecated.line-too-long.git-commit"] + + it "correctly parses the number sign of the first comment line", -> + commentLine = tokenizedBuffer.lineForScreenRow(1) + expect(commentLine.text).toBe "# Please enter the commit message for your changes. Lines starting" + { tokens } = commentLine + + expect(tokens[0].value).toBe "#" + expect(tokens[0].scopes).toEqual ["text.git-commit", "meta.scope.metadata.git-commit", "comment.line.number-sign.git-commit", "punctuation.definition.comment.git-commit"] + + describe "when a C++ source file is tokenized", -> + beforeEach -> + editSession = fixturesProject.buildEditSessionForPath('includes.cc', autoIndent: false) + buffer = editSession.buffer + tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer + editSession.setVisible(true) + fullyTokenize(tokenizedBuffer) + + afterEach -> + editSession.destroy() + + it "correctly parses the first include line", -> + longLine = tokenizedBuffer.lineForScreenRow(0) + expect(longLine.text).toBe '#include "a.h"' + { tokens } = longLine + + expect(tokens[0].value).toBe "#" + expect(tokens[0].scopes).toEqual ["source.c++", "meta.preprocessor.c.include"] + expect(tokens[1].value).toBe 'include' + expect(tokens[1].scopes).toEqual ["source.c++", "meta.preprocessor.c.include", "keyword.control.import.include.c"] + + it "correctly parses the second include line", -> + commentLine = tokenizedBuffer.lineForScreenRow(1) + expect(commentLine.text).toBe '#include "b.h"' + { tokens } = commentLine + + expect(tokens[0].value).toBe "#" + expect(tokens[0].scopes).toEqual ["source.c++", "meta.preprocessor.c.include"] + expect(tokens[1].value).toBe 'include' + expect(tokens[1].scopes).toEqual ["source.c++", "meta.preprocessor.c.include", "keyword.control.import.include.c"] diff --git a/spec/fixtures/COMMIT_EDITMSG b/spec/fixtures/COMMIT_EDITMSG new file mode 100644 index 000000000..d369aa22e --- /dev/null +++ b/spec/fixtures/COMMIT_EDITMSG @@ -0,0 +1,2 @@ +longggggggggggggggggggggggggggggggggggggggggggggggg +# Please enter the commit message for your changes. Lines starting diff --git a/spec/fixtures/includes.cc b/spec/fixtures/includes.cc new file mode 100644 index 000000000..600af314e --- /dev/null +++ b/spec/fixtures/includes.cc @@ -0,0 +1,2 @@ +#include "a.h" +#include "b.h" diff --git a/spec/stdlib/child-process-spec.coffee b/spec/stdlib/child-process-spec.coffee index 175793aa1..15b9be963 100644 --- a/spec/stdlib/child-process-spec.coffee +++ b/spec/stdlib/child-process-spec.coffee @@ -126,4 +126,4 @@ describe 'Child Processes', -> ChildProcess.exec(cmd, options) runs -> - expect(output.length).toBeGreaterThan 1 \ No newline at end of file + expect(output.length).toBeGreaterThan 1 diff --git a/src/app/gutter.coffee b/src/app/gutter.coffee index 4910e21ba..19e63708b 100644 --- a/src/app/gutter.coffee +++ b/src/app/gutter.coffee @@ -2,6 +2,8 @@ $ = require 'jquery' _ = require 'underscore' +Range = require 'range' +Point = require 'point' module.exports = class Gutter extends View @@ -18,9 +20,17 @@ class Gutter extends View @attached = true editor = @editor() - highlightCursorLine = => @highlightCursorLine() - editor.on 'cursor-move', highlightCursorLine - editor.on 'selection-change', highlightCursorLine + highlightLines = => @highlightLines() + editor.on 'cursor-move', highlightLines + editor.on 'selection-change', highlightLines + @on 'click', '.line-number', (e) => + row = parseInt($(e.target).text()) - 1 + position = new Point(row, 0) + if e.shiftKey + @editor().selectToScreenPosition(position) + else + @editor().setCursorScreenPosition(position) + @calculateWidth() editor: -> @@ -63,8 +73,8 @@ class Gutter extends View @calculateWidth() @firstScreenRow = startScreenRow @lastScreenRow = endScreenRow - @highlightedRow = null - @highlightCursorLine() + @highlightedRows = null + @highlightLines() calculateWidth: -> highestNumberWidth = @editor().getLineCount().toString().length * @editor().charWidth @@ -73,17 +83,38 @@ class Gutter extends View @lineNumbers.width(highestNumberWidth + @calculateLineNumberPadding()) @widthChanged?(@outerWidth()) - highlightCursorLine: -> - if @editor().getSelection().isEmpty() - rowToHighlight = @editor().getCursorScreenPosition().row - return if rowToHighlight == @highlightedRow - return if rowToHighlight < @firstScreenRow or rowToHighlight > @lastScreenRow + removeLineHighlights: -> + return unless @highlightedLineNumbers + for line in @highlightedLineNumbers + line.classList.remove('cursor-line') + line.classList.remove('cursor-line-no-selection') + @highlightedLineNumbers = null - @highlightedLineNumber?.classList.remove('cursor-line') - if @highlightedLineNumber = @lineNumbers[0].children[rowToHighlight - @firstScreenRow] - @highlightedLineNumber.classList.add('cursor-line') - @highlightedRow = rowToHighlight + addLineHighlight: (row, emptySelection) -> + return if row < @firstScreenRow or row > @lastScreenRow + @highlightedLineNumbers ?= [] + if highlightedLineNumber = @lineNumbers[0].children[row - @firstScreenRow] + highlightedLineNumber.classList.add('cursor-line') + highlightedLineNumber.classList.add('cursor-line-no-selection') if emptySelection + @highlightedLineNumbers.push(highlightedLineNumber) + + highlightLines: -> + if @editor().getSelection().isEmpty() + row = @editor().getCursorScreenPosition().row + rowRange = new Range([row, 0], [row, 0]) + return if @selectionEmpty and @highlightedRows?.isEqual(rowRange) + + @removeLineHighlights() + @addLineHighlight(row, true) + @highlightedRows = rowRange + @selectionEmpty = true else - @highlightedLineNumber?.classList.remove('cursor-line') - @highlightedLineNumber = null - @highlightedRow = null + selectedRows = @editor().getSelection().getScreenRange() + selectedRows = new Range([selectedRows.start.row, 0], [selectedRows.end.row, 0]) + return if not @selectionEmpty and @highlightedRows?.isEqual(selectedRows) + + @removeLineHighlights() + for row in [selectedRows.start.row..selectedRows.end.row] + @addLineHighlight(row, false) + @highlightedRows = selectedRows + @selectionEmpty = false diff --git a/src/app/language-mode.coffee b/src/app/language-mode.coffee index 98b1689b5..1305f9191 100644 --- a/src/app/language-mode.coffee +++ b/src/app/language-mode.coffee @@ -186,5 +186,5 @@ class LanguageMode if desiredIndentLevel < currentIndentLevel @editSession.setIndentationForBufferRow(bufferRow, desiredIndentLevel) - tokenizeLine: (line, stack) -> - {tokens, stack} = @grammar.tokenizeLine(line, stack) + tokenizeLine: (line, stack, firstLine) -> + {tokens, stack} = @grammar.tokenizeLine(line, stack, firstLine) diff --git a/src/app/text-mate-grammar.coffee b/src/app/text-mate-grammar.coffee index df72c8511..dbba03344 100644 --- a/src/app/text-mate-grammar.coffee +++ b/src/app/text-mate-grammar.coffee @@ -29,8 +29,7 @@ class TextMateGrammar data = {patterns: [data], tempName: name} if data.begin? or data.match? @repository[name] = new Rule(this, data) - tokenizeLine: (line, ruleStack=[@initialRule]) -> - ruleStack ?= [@initialRule] + tokenizeLine: (line, ruleStack=[@initialRule], firstLine=false) -> ruleStack = new Array(ruleStack...) # clone ruleStack tokens = [] position = 0 @@ -42,9 +41,9 @@ class TextMateGrammar tokens = [new Token(value: "", scopes: scopes)] return { tokens, ruleStack } - break if position == line.length + break if position == line.length + 1 # include trailing newline position - if match = _.last(ruleStack).getNextTokens(ruleStack, line, position) + if match = _.last(ruleStack).getNextTokens(ruleStack, line, position, firstLine) { nextTokens, tokensStartPosition, tokensEndPosition } = match if position < tokensStartPosition # unmatched text before next tokens tokens.push(new Token( @@ -56,12 +55,14 @@ class TextMateGrammar position = tokensEndPosition else # push filler token for unmatched text at end of line - tokens.push(new Token( - value: line[position...line.length] - scopes: scopes - )) + if position < line.length + tokens.push(new Token( + value: line[position...line.length] + scopes: scopes + )) break + ruleStack.forEach (rule) -> rule.clearAnchorPosition() { tokens, ruleStack } ruleForInclude: (name) -> @@ -79,6 +80,7 @@ class Rule patterns: null allPatterns: null createEndPattern: null + anchorPosition: -1 constructor: (@grammar, {@scopeName, patterns, @endPattern}) -> patterns ?= [] @@ -95,14 +97,30 @@ class Rule @allPatterns.push(pattern.getIncludedPatterns(included)...) @allPatterns - getScanner: -> - @scanner ?= new OnigScanner(_.pluck(@getIncludedPatterns(), 'regexSource')) + clearAnchorPosition: -> @anchorPosition = -1 - getNextTokens: (stack, line, position) -> + getScanner: (position, firstLine) -> + return @scanner if @scanner + + anchored = false + regexes = [] + @getIncludedPatterns().forEach (pattern) => + if pattern.anchored + anchored = true + regex = pattern.replaceAnchor(firstLine, position, @anchorPosition) + else + regex = pattern.regexSource + regexes.push regex if regex + + regexScanner = new OnigScanner(regexes) + @scanner = regexScanner unless anchored + regexScanner + + getNextTokens: (stack, line, position, firstLine) -> patterns = @getIncludedPatterns() # Add a `\n` to appease patterns that contain '\n' explicitly - return null unless result = @getScanner().findNextMatch(line + "\n", position) + return null unless result = @getScanner(position, firstLine).findNextMatch("#{line}\n", position) { index, captureIndices } = result # Since the `\n' (added above) is not part of the line, truncate captures to the line's actual length lineLength = line.length @@ -130,6 +148,7 @@ class Pattern scopeName: null captures: null backReferences: null + anchored: false constructor: (@grammar, { name, contentName, @include, match, begin, end, captures, beginCaptures, endCaptures, patterns, @popRule, hasBackReferences}) -> @scopeName = name ? contentName # TODO: We need special treatment of contentName @@ -144,6 +163,42 @@ class Pattern @captures = beginCaptures ? captures endPattern = new Pattern(@grammar, { match: end, captures: endCaptures ? captures, popRule: true}) @pushRule = new Rule(@grammar, { @scopeName, patterns, endPattern }) + @anchored = @hasAnchor() + + hasAnchor: -> + return false unless @regexSource + escape = false + for character in @regexSource.split('') + return true if escape and 'AGz'.indexOf(character) isnt -1 + escape = not escape and character is '\\' + false + + replaceAnchor: (firstLine, offset, anchor) -> + escaped = [] + placeholder = '\uFFFF' + escape = false + for character in @regexSource.split('') + if escape + switch character + when 'A' + if firstLine + escaped.push("\\#{character}") + else + escaped.push(placeholder) + when 'G' + if offset is anchor + escaped.push("\\#{character}") + else + escaped.push(placeholder) + when 'z' then escaped.push('$(?!\n)(? beginCaptures = [] @@ -180,7 +235,9 @@ class Pattern else tokens = [new Token(value: line[start...end], scopes: scopes)] if @pushRule - stack.push(@pushRule.getRuleToPush(line, captureIndices)) + ruleToPush = @pushRule.getRuleToPush(line, captureIndices) + ruleToPush.anchorPosition = captureIndices[2] + stack.push(ruleToPush) else if @popRule stack.pop() @@ -226,4 +283,3 @@ shiftCapture = (captureIndices) -> scopesFromStack = (stack) -> _.compact(_.pluck(stack, "scopeName")) - diff --git a/src/app/tokenized-buffer.coffee b/src/app/tokenized-buffer.coffee index 7ab97222a..bc88d4e0e 100644 --- a/src/app/tokenized-buffer.coffee +++ b/src/app/tokenized-buffer.coffee @@ -134,7 +134,7 @@ class TokenizedBuffer buildTokenizedScreenLineForRow: (row, ruleStack) -> line = @buffer.lineForRow(row) - { tokens, ruleStack } = @languageMode.tokenizeLine(line, ruleStack) + { tokens, ruleStack } = @languageMode.tokenizeLine(line, ruleStack, row is 0) new ScreenLine({tokens, ruleStack, @tabLength}) lineForScreenRow: (row) -> diff --git a/static/editor.css b/static/editor.css index 7c6e10db7..e1892bee1 100644 --- a/static/editor.css +++ b/static/editor.css @@ -33,7 +33,8 @@ color: rgba(255, 255, 255, .6); } -.editor.focused .cursor-line { +.editor.focused .line-number.cursor-line-no-selection, +.editor.focused .line.cursor-line { background-color: rgba(255, 255, 255, .12); }