diff --git a/src/scripting_api.nim b/src/scripting_api.nim index d2effe49..b93f881b 100644 --- a/src/scripting_api.nim +++ b/src/scripting_api.nim @@ -22,8 +22,10 @@ type SelectorPopup* = object id*: EditorId -type Cursor* = tuple[line, column: int] -type Selection* = tuple[first, last: Cursor] +type CursorT*[T] = tuple[line: int, column: T] +type SelectionT*[T] = tuple[first, last: CursorT[T]] +type Cursor* = CursorT[int] +type Selection* = SelectionT[int] type SelectionCursor* = enum Config = "config", Both = "both", First = "first", Last = "last", LastToFirst = "last-to-first" type LineNumbers* = enum None = "none", Absolute = "Absolute", Relative = "relative" type Backend* = enum Gui = "gui", Terminal = "terminal", Browser = "browser" diff --git a/src/text/language/language_server_base.nim b/src/text/language/language_server_base.nim index 759cf69f..f3be3af7 100644 --- a/src/text/language/language_server_base.nim +++ b/src/text/language/language_server_base.nim @@ -1,5 +1,5 @@ import std/[options, tables, json] -import misc/[custom_async, custom_logger, event] +import misc/[custom_async, custom_logger, event, custom_unicode] import scripting_api except DocumentEditor, TextDocumentEditor, AstDocumentEditor import workspaces/workspace @@ -79,6 +79,17 @@ type InlayHint* = object paddingRight*: bool data*: Option[JsonNode] +type Diagnostic* = object + selection*: Selection + severity*: Option[lsp_types.DiagnosticSeverity] + code*: Option[JsonNode] + codeDescription*: lsp_types.CodeDescription + source*: Option[string] + message*: string + tags*: seq[lsp_types.DiagnosticTag] + relatedInformation*: Option[seq[lsp_types.DiagnosticRelatedInformation]] + data*: Option[JsonNode] + var getOrCreateLanguageServer*: proc(languageId: string, filename: string, workspaces: seq[string], languagesServer: Option[(string, int)] = (string, int).none, workspace = WorkspaceFolder.none): Future[Option[LanguageServer]] = nil method start*(self: LanguageServer): Future[void] {.base.} = discard @@ -92,7 +103,7 @@ method saveTempFile*(self: LanguageServer, filename: string, content: string): F method getSymbols*(self: LanguageServer, filename: string): Future[seq[Symbol]] {.base.} = discard method getHover*(self: LanguageServer, filename: string, location: Cursor): Future[Option[string]] {.base.} = discard method getInlayHints*(self: LanguageServer, filename: string, selection: Selection): Future[seq[InlayHint]] {.base.} = discard -method getDiagnostics*(self: LanguageServer, filename: string): Future[lsp_types.Response[seq[lsp_types.Diagnostic]]] {.base.} = discard +method getDiagnostics*(self: LanguageServer, filename: string): Future[lsp_types.Response[seq[Diagnostic]]] {.base.} = discard var handleIdCounter = 1 diff --git a/src/text/language/language_server_lsp.nim b/src/text/language/language_server_lsp.nim index f37c607e..1a24c83d 100644 --- a/src/text/language/language_server_lsp.nim +++ b/src/text/language/language_server_lsp.nim @@ -1,6 +1,6 @@ import std/[strutils, options, json, tables, uri, strformat, sugar, sequtils] import scripting_api except DocumentEditor, TextDocumentEditor, AstDocumentEditor -import misc/[event, util, custom_logger, custom_async, myjsonutils] +import misc/[event, util, custom_logger, custom_async, myjsonutils, custom_unicode] import text/text_editor import language_server_base, app, app_interface, config_provider, lsp_client import workspaces/workspace as ws @@ -314,21 +314,33 @@ method getSymbols*(self: LanguageServerLSP, filename: string): Future[seq[Symbol return completions -method getDiagnostics*(self: LanguageServerLSP, filename: string): Future[Response[seq[Diagnostic]]] {.async.} = +method getDiagnostics*(self: LanguageServerLSP, filename: string): Future[Response[seq[language_server_base.Diagnostic]]] {.async.} = debugf"getDiagnostics: {filename}" let response = await self.client.getDiagnostics(filename) if response.isError: log(lvlError, &"Error: {response.error.code}\n{response.error.message}") - return response.to(seq[Diagnostic]) + return response.to(seq[language_server_base.Diagnostic]) let report = response.result debugf"getDiagnostics: {report}" - var res: seq[Diagnostic] + var res: seq[language_server_base.Diagnostic] if report.asRelatedFullDocumentDiagnosticReport().getSome(report): - res = report.items + # todo: selection from rune index to byte index + for d in report.items: + res.add language_server_base.Diagnostic( + # selection: ((d.`range`.start.line, d.`range`.start.character.RuneIndex), (d.`range`.`end`.line, d.`range`.`end`.character.RuneIndex)), + severity: d.severity, + code: d.code, + codeDescription: d.codeDescription, + source: d.source, + message: d.message, + tags: d.tags, + relatedInformation: d.relatedInformation, + data: d.data, + ) debugf"items: {res.len}: {res}" return res.success diff --git a/src/text/text_document.nim b/src/text/text_document.nim index f0980949..b908dead 100644 --- a/src/text/text_document.nim +++ b/src/text/text_document.nim @@ -92,7 +92,7 @@ type TextDocument* = ref object of Document styledTextCache: Table[int, StyledLine] - currentDiagnostics*: seq[lsp_types.Diagnostic] + currentDiagnostics*: seq[Diagnostic] onDiagnosticsUpdated*: Event[void] proc nextLineId*(self: TextDocument): int32 = @@ -277,6 +277,15 @@ proc lspRangeToSelection*(self: TextDocument, `range`: lsp_types.Range): Selecti let lastColumn = self.lines[`range`.`end`.line].runeOffset(`range`.`end`.character.RuneIndex) return ((`range`.start.line, firstColumn), (`range`.`end`.line, lastColumn)) +proc runeCursorToCursor*(self: TextDocument, cursor: CursorT[RuneIndex]): Cursor = + if cursor.line > self.lines.high or cursor.line > self.lines.high: + return (0, 0) + + return (cursor.line, self.lines[cursor.line].runeOffset(cursor.column)) + +proc runeSelectionToSelection*(self: TextDocument, cursor: SelectionT[RuneIndex]): Selection = + return (self.runeCursorToCursor(cursor.first), self.runeCursorToCursor(cursor.last)) + func len*(line: StyledLine): int = result = 0 for p in line.parts: @@ -716,7 +725,20 @@ proc getLanguageServer*(self: TextDocument): Future[Option[LanguageServer]] {.as let diagnosticsHandle = ls.onDiagnostics.subscribe proc(diagnostics: lsp_types.PublicDiagnosticsParams) = let uri = diagnostics.uri.decodeUrl.parseUri if uri.path.normalizePathUnix == self.filename: - self.currentDiagnostics = diagnostics.diagnostics + self.currentDiagnostics.setLen diagnostics.diagnostics.len + for i, d in diagnostics.diagnostics: + self.currentDiagnostics[i] = language_server_base.Diagnostic( + selection: self.runeSelectionToSelection(((d.`range`.start.line, d.`range`.start.character.RuneIndex), (d.`range`.`end`.line, d.`range`.`end`.character.RuneIndex))), + severity: d.severity, + code: d.code, + codeDescription: d.codeDescription, + source: d.source, + message: d.message, + tags: d.tags, + relatedInformation: d.relatedInformation, + data: d.data, + ) + self.onDiagnosticsUpdated.invoke() return self.languageServer @@ -729,68 +751,6 @@ proc byteOffset*(self: TextDocument, cursor: Cursor): int = proc tabWidth*(self: TextDocument): int = return self.languageConfig.map(c => c.tabWidth).get(4) -proc delete*(self: TextDocument, selections: openArray[Selection], oldSelection: openArray[Selection], notify: bool = true, record: bool = true, inclusiveEnd: bool = false): seq[Selection] = - result = self.clampAndMergeSelections selections - - var undoOp = UndoOp(kind: Nested, children: @[], oldSelection: @oldSelection) - - for i, selectionRaw in result: - let normalizedSelection = selectionRaw.normalized - let selection: Selection = if inclusiveEnd and self.lines[normalizedSelection.last.line].len > 0: - let nextColumn = self.lines[normalizedSelection.last.line].nextRuneStart(min(normalizedSelection.last.column, self.lines[normalizedSelection.last.line].high)).int - (normalizedSelection.first, (normalizedSelection.last.line, nextColumn)) - else: - normalizedSelection - - if selection.isEmpty: - continue - - let (first, last) = selection - - let startByte = self.byteOffset(first) - let endByte = self.byteOffset(last) - - let deletedText = self.contentString(selection) - - if first.line == last.line: - # Single line selection - self.lines[last.line].delete first.column.. 0: - undoOp.checkpoints.add self.nextCheckpoints - self.undoOps.add undoOp - self.redoOps = @[] - - self.nextCheckpoints = @[] - proc getNodeRange*(self: TextDocument, selection: Selection, parentIndex: int = 0, siblingIndex: int = 0): Option[Selection] = result = Selection.none let tree = self.tsTree @@ -906,6 +866,93 @@ proc traverse*(line, column: int, text: openArray[char]): (int, int) = return (line, column) +func charCategory(c: char): int = + if c.isAlphaNumeric or c == '_': return 0 + if c in Whitespace: return 1 + return 2 + +proc findWordBoundary*(self: TextDocument, cursor: Cursor): Selection = + let line = self.getLine cursor.line + result = cursor.toSelection + if result.first.column == line.len: + dec result.first.column + dec result.last.column + + # Search to the left + while result.first.column > 0 and result.first.column < line.len: + let leftCategory = line[result.first.column - 1].charCategory + let rightCategory = line[result.first.column].charCategory + if leftCategory != rightCategory: + break + result.first.column -= 1 + + # Search to the right + if result.last.column < line.len: + result.last.column += 1 + while result.last.column >= 0 and result.last.column < line.len: + let leftCategory = line[result.last.column - 1].charCategory + let rightCategory = line[result.last.column].charCategory + if leftCategory != rightCategory: + break + result.last.column += 1 + +proc updateCursorAfterInsert*(self: TextDocument, location: Cursor, inserted: Selection): Cursor = + result = location + if result.line == inserted.first.line and result.column > inserted.first.column: + # inserted text on same line before inlayHint + result.column += inserted.last.column - inserted.first.column + result.line += inserted.last.line - inserted.first.line + elif result.line == inserted.first.line and result.column == inserted.first.column: + # Inserted text at inlayHint location + # Usually the inlay hint will be on a word boundary, so if it's not anymore then move the inlay hint + # (this happens if you e.g. change the name of the variable by appending some text) + # If it's still on a word boundary, then the new inlay hint will probably be at the same location so don't move it now + let wordBoundary = self.findWordBoundary(result) + if result != wordBoundary.first and result != wordBoundary.last: + result.column += inserted.last.column - inserted.first.column + result.line += inserted.last.line - inserted.first.line + elif result.line > inserted.first.line: + # inserted text on line before inlay hint + result.line += inserted.last.line - inserted.first.line + +proc updateCursorAfterDelete*(self: TextDocument, location: Cursor, deleted: Selection): Option[Cursor] = + var res = location + if deleted.first.line == deleted.last.line: + if res.line == deleted.first.line and res.column >= deleted.last.column: + res.column -= deleted.last.column - deleted.first.column + elif res.line == deleted.first.line and res.column > deleted.first.column and res.column < deleted.last.column: + return Cursor.none + + else: + if res.line == deleted.first.line and res.column >= deleted.first.column: + return Cursor.none + if res.line > deleted.first.line and res.line < deleted.last.line: + return Cursor.none + if res.line == deleted.last.line and res.column <= deleted.last.column: + return Cursor.none + + if res.line == deleted.last.line and res.column >= deleted.last.column: + res.column -= deleted.last.column - deleted.first.column + + if res.line >= deleted.last.line: + res.line -= deleted.last.line - deleted.first.line + + return res.some + +proc updateDiagnosticPositionsAfterInsert(self: TextDocument, inserted: Selection) = + for d in self.currentDiagnostics.mitems: + d.selection.first = self.updateCursorAfterInsert(d.selection.first, inserted) + d.selection.last = self.updateCursorAfterInsert(d.selection.last, inserted) + +proc updateDiagnosticPositionsAfterDelete(self: TextDocument, selection: Selection) = + let selection = selection.normalized + for i in countdown(self.currentDiagnostics.high, 0): + if self.updateCursorAfterDelete(self.currentDiagnostics[i].selection.first, selection).getSome(first) and + self.updateCursorAfterDelete(self.currentDiagnostics[i].selection.last, selection).getSome(last): + self.currentDiagnostics[i].selection = (first, last) + else: + self.currentDiagnostics.removeSwap(i) + proc insert*(self: TextDocument, selections: openArray[Selection], oldSelection: openArray[Selection], texts: openArray[string], notify: bool = true, record: bool = true): seq[Selection] = # be careful with logging inside this function, because the logs are written to another document using this function to insert, which can cause infinite recursion # when inserting a log line logs something. @@ -999,6 +1046,7 @@ proc insert*(self: TextDocument, selections: openArray[Selection], oldSelection: undoOp.children.add UndoOp(kind: Delete, selection: (oldCursor, cursor)) + self.updateDiagnosticPositionsAfterInsert (oldCursor, cursor) if notify: self.textInserted.invoke((self, (oldCursor, cursor), text)) @@ -1015,6 +1063,69 @@ proc insert*(self: TextDocument, selections: openArray[Selection], oldSelection: self.nextCheckpoints = @[] +proc delete*(self: TextDocument, selections: openArray[Selection], oldSelection: openArray[Selection], notify: bool = true, record: bool = true, inclusiveEnd: bool = false): seq[Selection] = + result = self.clampAndMergeSelections selections + + var undoOp = UndoOp(kind: Nested, children: @[], oldSelection: @oldSelection) + + for i, selectionRaw in result: + let normalizedSelection = selectionRaw.normalized + let selection: Selection = if inclusiveEnd and self.lines[normalizedSelection.last.line].len > 0: + let nextColumn = self.lines[normalizedSelection.last.line].nextRuneStart(min(normalizedSelection.last.column, self.lines[normalizedSelection.last.line].high)).int + (normalizedSelection.first, (normalizedSelection.last.line, nextColumn)) + else: + normalizedSelection + + if selection.isEmpty: + continue + + let (first, last) = selection + + let startByte = self.byteOffset(first) + let endByte = self.byteOffset(last) + + let deletedText = self.contentString(selection) + + if first.line == last.line: + # Single line selection + self.lines[last.line].delete first.column.. 0: + undoOp.checkpoints.add self.nextCheckpoints + self.undoOps.add undoOp + self.redoOps = @[] + + self.nextCheckpoints = @[] + proc edit*(self: TextDocument, selections: openArray[Selection], oldSelection: openArray[Selection], texts: openArray[string], notify: bool = true, record: bool = true, inclusiveEnd: bool = false): seq[Selection] = let selections = selections.map (s) => s.normalized result = self.delete(selections, oldSelection, record=record, inclusiveEnd=inclusiveEnd) diff --git a/src/text/text_editor.nim b/src/text/text_editor.nim index 3120d99b..af9cbaf5 100644 --- a/src/text/text_editor.nim +++ b/src/text/text_editor.nim @@ -10,8 +10,6 @@ import workspaces/[workspace] import document, document_editor, events, vmath, bumpy, input, custom_treesitter, indent, text_document import config_provider, app_interface -from language/lsp_types as lsp_types import nil - export text_document, document_editor, id logCategory "texted" @@ -80,7 +78,7 @@ type TextDocumentEditor* = ref object of DocumentEditor diagnosticsPerLine*: Table[int, seq[int]] showDiagnostic*: bool = false currentDiagnosticLine*: int - currentDiagnostic*: lsp_types.Diagnostic + currentDiagnostic*: Diagnostic completionEventHandler: EventHandler modeEventHandler: EventHandler @@ -1098,35 +1096,8 @@ proc runAction*(self: TextDocumentEditor, action: string, args: JsonNode): bool # echo "runAction ", action, ", ", $args return self.handleActionInternal(action, args) == Handled -func charCategory(c: char): int = - if c.isAlphaNumeric or c == '_': return 0 - if c in Whitespace: return 1 - return 2 - proc findWordBoundary*(self: TextDocumentEditor, cursor: Cursor): Selection {.expose("editor.text").} = - let line = self.document.getLine cursor.line - result = cursor.toSelection - if result.first.column == line.len: - dec result.first.column - dec result.last.column - - # Search to the left - while result.first.column > 0 and result.first.column < line.len: - let leftCategory = line[result.first.column - 1].charCategory - let rightCategory = line[result.first.column].charCategory - if leftCategory != rightCategory: - break - result.first.column -= 1 - - # Search to the right - if result.last.column < line.len: - result.last.column += 1 - while result.last.column >= 0 and result.last.column < line.len: - let leftCategory = line[result.last.column - 1].charCategory - let rightCategory = line[result.last.column].charCategory - if leftCategory != rightCategory: - break - result.last.column += 1 + self.document.findWordBoundary(cursor) proc getSelectionInPair*(self: TextDocumentEditor, cursor: Cursor, delimiter: char): Selection {.expose("editor.text").} = result = cursor.toSelection @@ -1194,7 +1165,7 @@ proc getSelectionForMove*(self: TextDocumentEditor, cursor: Cursor, move: string if result.first.line > 0: result.first = (result.first.line - 1, self.document.lineLength(result.first.line - 1)) for _ in 1.. 0: result.first = (result.first.line - 1, self.document.lineLength(result.first.line - 1)) @@ -1748,7 +1719,7 @@ proc updateDiagnosticsForCurrent*(self: TextDocumentEditor) {.expose("editor.tex proc showDiagnosticsForCurrent*(self: TextDocumentEditor) {.expose("editor.text").} = let line = self.selection.last.line - if self.showDiagnostic and self.currentDiagnostic.`range`.start.line == line: + if self.showDiagnostic and self.currentDiagnostic.selection.first.line == line: self.showDiagnostic = false self.markDirty() return @@ -2095,51 +2066,20 @@ method injectDependencies*(self: TextDocumentEditor, app: AppInterface) = self.setMode(self.configProvider.getValue("editor.text.default-mode", "")) -proc updateInlayHintPositionsAfterInsert(self: TextDocumentEditor, location: Selection) = - # todo: correctly handle unicode +proc updateInlayHintPositionsAfterInsert(self: TextDocumentEditor, inserted: Selection) = for inlayHint in self.inlayHints.mitems: - if inlayHint.location.line == location.first.line and inlayHint.location.column > location.first.column: - # inserted text on same line before inlayHint - inlayHint.location.column += location.last.column - location.first.column - inlayHint.location.line += location.last.line - location.first.line - elif inlayHint.location.line == location.first.line and inlayHint.location.column == location.first.column: - # Inserted text at inlayHint location - # Usually the inlay hint will be on a word boundary, so if it's not anymore then move the inlay hint - # (this happens if you e.g. change the name of the variable by appending some text) - # If it's still on a word boundary, then the new inlay hint will probably be at the same location so don't move it now - let wordBoundary = self.findWordBoundary(inlayHint.location) - if inlayHint.location != wordBoundary.first and inlayHint.location != wordBoundary.last: - inlayHint.location.column += location.last.column - location.first.column - inlayHint.location.line += location.last.line - location.first.line - elif inlayHint.location.line > location.first.line: - # inserted text on line before inlay hint - inlayHint.location.line += location.last.line - location.first.line + # todo: correctly handle unicode (inlay hint location comes from lsp, so probably a RuneIndex) + inlayHint.location = self.document.updateCursorAfterInsert(inlayHint.location, inserted) proc updateInlayHintPositionsAfterDelete(self: TextDocumentEditor, selection: Selection) = - # todo: correctly handle unicode (inlay hint location comes from lsp, so probably a RuneIndex) let selection = selection.normalized for i in countdown(self.inlayHints.high, 0): + # todo: correctly handle unicode (inlay hint location comes from lsp, so probably a RuneIndex) template inlayHint: InlayHint = self.inlayHints[i] - - if selection.first.line == selection.last.line: - if inlayHint.location.line == selection.first.line and inlayHint.location.column >= selection.last.column: - inlayHint.location.column -= selection.last.column - selection.first.column - elif inlayHint.location.line == selection.first.line and inlayHint.location.column > selection.first.column and inlayHint.location.column < selection.last.column: - self.inlayHints.removeSwap(i) + if self.document.updateCursorAfterDelete(inlayHint.location, selection).getSome(location): + self.inlayHints[i].location = location else: - var remove = false - if inlayHint.location.line == selection.first.line and inlayHint.location.column >= selection.first.column: - remove = true - elif inlayHint.location.line > selection.first.line and inlayHint.location.line < selection.last.line: - remove = true - elif inlayHint.location.line == selection.last.line and inlayHint.location.column <= selection.last.column: - remove = true - elif inlayHint.location.line == selection.last.line and inlayHint.location.column >= selection.last.column: - inlayHint.location.column -= selection.last.column - selection.first.column - if inlayHint.location.line >= selection.last.line: - inlayHint.location.line -= selection.last.line - selection.first.line - if remove: - self.inlayHints.removeSwap(i) + self.inlayHints.removeSwap(i) proc handleTextInserted(self: TextDocumentEditor, document: TextDocument, location: Selection, text: string) = self.updateInlayHintPositionsAfterInsert(location) @@ -2147,20 +2087,6 @@ proc handleTextInserted(self: TextDocumentEditor, document: TextDocument, locati proc handleTextDeleted(self: TextDocumentEditor, document: TextDocument, selection: Selection) = self.updateInlayHintPositionsAfterDelete(selection) -proc handleTextDocumentTextChanged(self: TextDocumentEditor) = - self.clampSelection() - self.updateSearchResults() - - if self.showCompletions and self.updateCompletionsTask.isNotNil: - self.updateCompletionsTask.reschedule() - - if self.showCompletions: - self.refilterCompletions() - - self.updateInlayHints() - - self.markDirty() - proc handleDiagnosticsUpdated(self: TextDocumentEditor) = log lvlInfo, fmt"Got diagnostics for {self.document.filename}: {self.document.currentDiagnostics}" self.clearCustomHighlights(diagnosticsHighlightId) @@ -2168,7 +2094,7 @@ proc handleDiagnosticsUpdated(self: TextDocumentEditor) = self.diagnosticsPerLine.clear() for i, d in self.document.currentDiagnostics: - let selection = self.document.lspRangeToSelection(d.`range`) + let selection = d.selection let colorName = if d.severity.getSome(severity): case severity @@ -2184,6 +2110,21 @@ proc handleDiagnosticsUpdated(self: TextDocumentEditor) = self.updateDiagnosticsForCurrent() +proc handleTextDocumentTextChanged(self: TextDocumentEditor) = + self.clampSelection() + self.updateSearchResults() + + if self.showCompletions and self.updateCompletionsTask.isNotNil: + self.updateCompletionsTask.reschedule() + + if self.showCompletions: + self.refilterCompletions() + + self.updateInlayHints() + self.handleDiagnosticsUpdated() + + self.markDirty() + proc scrollToCursorAfterDelayAsync(self: TextDocumentEditor) {.async.} = await sleepAsync(32) self.scrollToCursor(self.selection.last)