Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion src/malls/lsp/classes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
from tree_sitter import Tree
import logging

import tree_sitter_mal as ts_mal
from tree_sitter import Language, Parser, Point, Tree

from ..ts.utils import lsp_to_tree_sitter_position
from .models import Position

MAL_LANGUAGE = Language(ts_mal.language())
PARSER = Parser(MAL_LANGUAGE)

log = logging.getLogger(__name__)


class Document:
Expand All @@ -11,3 +22,84 @@ class Document:
def __init__(self, tree: Tree, text: str):
self.text = text # text is in bytes
self.tree = tree

def _pos_to_byte(self, point: Point):
line, column = point.row, point.column
lines = self.text.split(b"\n")

byte_offset = 0

for i in range(line):
byte_offset += len(lines[i]) + 1 # +1 for the newline character

# Add the column position in the current line
byte_offset += column

return byte_offset

def _change_text(self, start: Point, end: Point, new_text: str) -> (int, int):
"""
Auxiliary method to simply edit the text corresponding to
the document
"""
# we need to convert from position to byte offset
start_byte, end_byte = self._pos_to_byte(start), self._pos_to_byte(end)
old_final_end_byte = self.tree.root_node.end_byte

# we also need to check if the end byte is bigger than the current
# text length
if old_final_end_byte < end_byte:
# replace end of file
self.text = self.text[:start_byte] + new_text
# therefore, the end of the file was the last changed byte
old_end_byte = old_final_end_byte
else:
# otherwise, we are changing in the middle of the file
self.text = self.text[:start_byte] + new_text + self.text[end_byte:]
# therefore, we only replaced things
old_end_byte = end_byte

return (start_byte, end_byte, old_end_byte)

def execute_changes(self, change_range: dict, text: str) -> None:
"""
This function will process changes to files. To do this, the source
code must be edited, TreeSitter alerted of the location of the changes
and finally the code must be reparsed.
"""

# start by converting the range to TreeSitter positions
start_position_lsp = Position(
line=change_range.start.line, character=change_range.start.character
)
end_position_lsp = Position(
line=change_range.end.line, character=change_range.end.character
)

start_position = lsp_to_tree_sitter_position(self.text, start_position_lsp, text)
end_position = lsp_to_tree_sitter_position(self.text, end_position_lsp, text)

# change text
start_byte, end_byte, old_end_byte = self._change_text(start_position, end_position, text)

# alert Treesitter of changes
self.tree.edit(
start_byte,
old_end_byte,
end_byte,
start_position,
end_position if old_end_byte == end_byte else self.tree.root_node.end_point,
end_position,
)

# reparse
self.tree = PARSER.parse(self.text, self.tree)

def change_whole_file(self, text: str) -> None:
"""
Since we have a completely new file, we can simply replace the text and
reparse, as this is not a change to the tree - it's a new tree
"""

self.text = text
self.tree = PARSER.parse(self.text)
34 changes: 34 additions & 0 deletions src/malls/lsp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2615,3 +2615,37 @@ class DidOpenTextDocumentParams(BaseModel):
"""

textDocument: TextDocumentItem


class WholeFileChange(BaseModel):
"""
Class to represent a change to the whole file
"""

text: str


class TextDocumentContentChangeEvent(BaseModel):
"""
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent
"""

range: Range

range_length: int | None = None

text: str | WholeFileChange

model_config = base_config


class DidChangeTextDocumentParams(BaseModel):
"""
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didChangeTextDocumentParams
"""

text_document: VersionedTextDocumentIdentifier

content_changes: list[TextDocumentContentChangeEvent]

model_config = base_config
23 changes: 23 additions & 0 deletions src/malls/mal_lsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def state(self) -> LifecycleFSM:
def trace_value(self) -> TraceValue:
return self.__trace_value

@property
def files(self) -> dict:
return self.__files

Expand Down Expand Up @@ -281,3 +282,25 @@ def m_text_document__did_open(self, **params: dict | None) -> None:

# with the opened file and included files parsed, we are done
return

def m_text_document__did_change(self, **params: dict | None) -> None:
"""
This function will process changes to files. To do this, the source
code must be edited, TreeSitter alerted of the location of the changes
and finally the code must be reparsed.
"""

# Obtain text document
textDocument = models.DidChangeTextDocumentParams(**params)
doc_uri = uri_to_path(textDocument.text_document.uri)
document = self.__files[doc_uri]

# There could be various changes, so we need to iterate over them
for change in textDocument.content_changes:
changed_range = change.range
if type(change.text) is str:
text = change.text.encode()
document.execute_changes(changed_range, text)
else:
text = change.text.text.encode() # whole file change
document.change_whole_file(text)
13 changes: 9 additions & 4 deletions src/malls/ts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,21 @@ def find_current_scope(cursor: TreeCursor, point: Point):
return owner


def lsp_to_tree_sitter_position(text: str, pos: Position) -> Point:
def lsp_to_tree_sitter_position(text: str, pos: Position, new_text: str = None) -> Point:
"""
Converts an LSP position (UTF-16 character index) to a Tree-sitter position (UTF-8 byte offset).
"""
lsp_line, lsp_char = pos.line, pos.character

lines = text.splitlines(keepends=True)

# Get correct line
line_text = lines[lsp_line]
if lsp_line >= len(lines):
# there is an extension to the text itself (didChange)
# so we have to consider the line being written
line_text = new_text.splitlines(keepends=True)[lsp_line - len(lines)]
else:
# Get correct line
line_text = lines[lsp_line]

# The idea is to conver the string to UTF-16.
# Since UTF-16 characters correspond to 2 bytes,
Expand All @@ -175,7 +180,7 @@ def lsp_to_tree_sitter_position(text: str, pos: Position) -> Point:

# Convert to UTF-16 (each UTF-16 code unit is 2 bytes)
# https://en.wikipedia.org/wiki/UTF-16#Byte-order_encoding_schemes
line_utf16 = line_text.encode("utf-16")
line_utf16 = line_text.decode().encode("utf-16")

# check for BOM
bom_size = 0
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

from .util import (
BASE_OPEN_FILE,
CHANGE_FILE_1,
CHANGE_FILE_2,
CHANGE_FILE_3,
CHANGE_FILE_4,
CHANGE_FILE_5,
OPEN_FILE_WITH_FAKE_INCLUDE,
OPEN_FILE_WITH_INCLUDED_FILE,
build_payload,
Expand Down Expand Up @@ -66,6 +71,11 @@ def template() -> typing.BinaryIO:
([BASE_OPEN_FILE], fixture_name + "_base_open_file"),
([OPEN_FILE_WITH_INCLUDED_FILE], fixture_name + "_with_included_file"),
([OPEN_FILE_WITH_FAKE_INCLUDE], fixture_name + "_with_fake_include"),
([BASE_OPEN_FILE, CHANGE_FILE_1], "change_middle_of_file_single_line"),
([BASE_OPEN_FILE, CHANGE_FILE_2], "change_middle_of_file_multiple_lines"),
([BASE_OPEN_FILE, CHANGE_FILE_3], "change_end_of_file"),
([BASE_OPEN_FILE, CHANGE_FILE_4], "change_middle_of_file_twice"),
([BASE_OPEN_FILE, CHANGE_FILE_5], "change_whole_file"),
]
for payload, new_name in payloads:
fixture = pytest.fixture(
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/writeable_fixtures/did_change_notif.in.lsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Content-Length: 72

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"trace":"off"}}
Content-Length: 41

{"jsonrpc":"2.0","method":"initialized"}
Loading
Loading