From e087aeb44f44072435a2c998b7341de28d9321fe Mon Sep 17 00:00:00 2001 From: James Westman Date: Sun, 23 Jul 2023 21:11:00 -0500 Subject: [PATCH 1/7] lsp: Add document outline --- blueprintcompiler/ast_utils.py | 37 +++++++++++++++- blueprintcompiler/language/adw_breakpoint.py | 35 +++++++++++++++- .../language/adw_message_dialog.py | 19 +++++++++ blueprintcompiler/language/common.py | 9 +++- blueprintcompiler/language/gobject_object.py | 17 +++++++- .../language/gobject_property.py | 15 +++++++ blueprintcompiler/language/gobject_signal.py | 12 ++++++ blueprintcompiler/language/gtk_a11y.py | 19 +++++++++ .../language/gtk_combo_box_text.py | 21 +++++++++- blueprintcompiler/language/gtk_file_filter.py | 18 ++++++++ blueprintcompiler/language/gtk_layout.py | 19 +++++++++ .../language/gtk_list_item_factory.py | 15 ++++++- blueprintcompiler/language/gtk_menu.py | 30 +++++++++++-- blueprintcompiler/language/gtk_scale.py | 19 +++++++++ blueprintcompiler/language/gtk_size_group.py | 18 ++++++++ blueprintcompiler/language/gtk_string_list.py | 18 ++++++++ blueprintcompiler/language/gtk_styles.py | 18 ++++++++ .../language/gtkbuilder_template.py | 15 +++++-- blueprintcompiler/lsp.py | 26 ++++++++++++ blueprintcompiler/lsp_utils.py | 42 ++++++++++++++++++- blueprintcompiler/parse_tree.py | 37 +++++++++++++--- blueprintcompiler/parser.py | 3 +- blueprintcompiler/tokenizer.py | 24 +++++++++++ tests/test_samples.py | 11 ++--- 24 files changed, 469 insertions(+), 28 deletions(-) diff --git a/blueprintcompiler/ast_utils.py b/blueprintcompiler/ast_utils.py index 7eebe45..59331f9 100644 --- a/blueprintcompiler/ast_utils.py +++ b/blueprintcompiler/ast_utils.py @@ -22,7 +22,8 @@ from collections import ChainMap, defaultdict from functools import cached_property from .errors import * -from .lsp_utils import SemanticToken +from .lsp_utils import DocumentSymbol, SemanticToken +from .tokenizer import Range TType = T.TypeVar("TType") @@ -54,6 +55,18 @@ class Children: return [child for child in self._children if isinstance(child, key)] +class Ranges: + def __init__(self, ranges: T.Dict[str, Range]): + self._ranges = ranges + + def __getitem__(self, key: T.Union[str, tuple[str, str]]) -> T.Optional[Range]: + if isinstance(key, str): + return self._ranges.get(key) + elif isinstance(key, tuple): + start, end = key + return Range.join(self._ranges.get(start), self._ranges.get(end)) + + TCtx = T.TypeVar("TCtx") TAttr = T.TypeVar("TAttr") @@ -102,6 +115,10 @@ class AstNode: def context(self): return Ctx(self) + @cached_property + def ranges(self): + return Ranges(self.group.ranges) + @cached_property def root(self): if self.parent is None: @@ -109,6 +126,10 @@ class AstNode: else: return self.parent.root + @property + def range(self): + return Range(self.group.start, self.group.end, self.group.text) + def parent_by_type(self, type: T.Type[TType]) -> TType: if self.parent is None: raise CompilerBugError() @@ -175,6 +196,20 @@ class AstNode: for child in self.children: yield from child.get_semantic_tokens() + @property + def document_symbol(self) -> T.Optional[DocumentSymbol]: + return None + + def get_document_symbols(self) -> T.List[DocumentSymbol]: + result = [] + for child in self.children: + if s := child.document_symbol: + s.children = child.get_document_symbols() + result.append(s) + else: + result.extend(child.get_document_symbols()) + return result + def validate_unique_in_parent( self, error: str, check: T.Optional[T.Callable[["AstNode"], bool]] = None ): diff --git a/blueprintcompiler/language/adw_breakpoint.py b/blueprintcompiler/language/adw_breakpoint.py index e71b29a..aec4ab5 100644 --- a/blueprintcompiler/language/adw_breakpoint.py +++ b/blueprintcompiler/language/adw_breakpoint.py @@ -35,6 +35,16 @@ class AdwBreakpointCondition(AstNode): def condition(self) -> str: return self.tokens["condition"] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "condition", + SymbolKind.Property, + self.range, + self.group.tokens["kw"].range, + self.condition, + ) + @docs("kw") def keyword_docs(self): klass = self.root.gir.get_type("Breakpoint", "Adw") @@ -93,6 +103,16 @@ class AdwBreakpointSetter(AstNode): else: return None + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + f"{self.object_id}.{self.property_name}", + SymbolKind.Property, + self.range, + self.group.tokens["object"].range, + self.value.range.text, + ) + @context(ValueTypeCtx) def value_type(self) -> ValueTypeCtx: if self.gir_property is not None: @@ -147,12 +167,25 @@ class AdwBreakpointSetter(AstNode): class AdwBreakpointSetters(AstNode): - grammar = ["setters", Match("{").expected(), Until(AdwBreakpointSetter, "}")] + grammar = [ + Keyword("setters"), + Match("{").expected(), + Until(AdwBreakpointSetter, "}"), + ] @property def setters(self) -> T.List[AdwBreakpointSetter]: return self.children[AdwBreakpointSetter] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "setters", + SymbolKind.Struct, + self.range, + self.group.tokens["setters"].range, + ) + @validate() def container_is_breakpoint(self): validate_parent_type(self, "Adw", "Breakpoint", "breakpoint setters") diff --git a/blueprintcompiler/language/adw_message_dialog.py b/blueprintcompiler/language/adw_message_dialog.py index 98c40cd..fb2be66 100644 --- a/blueprintcompiler/language/adw_message_dialog.py +++ b/blueprintcompiler/language/adw_message_dialog.py @@ -84,6 +84,16 @@ class ExtAdwMessageDialogResponse(AstNode): def value(self) -> StringValue: return self.children[0] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.id, + SymbolKind.Field, + self.range, + self.group.tokens["id"].range, + self.value.range.text, + ) + @context(ValueTypeCtx) def value_type(self) -> ValueTypeCtx: return ValueTypeCtx(StringType()) @@ -108,6 +118,15 @@ class ExtAdwMessageDialog(AstNode): def responses(self) -> T.List[ExtAdwMessageDialogResponse]: return self.children + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "responses", + SymbolKind.Array, + self.range, + self.group.tokens["responses"].range, + ) + @validate("responses") def container_is_message_dialog(self): validate_parent_type(self, "Adw", "MessageDialog", "responses") diff --git a/blueprintcompiler/language/common.py b/blueprintcompiler/language/common.py index e45cddc..e1bfa36 100644 --- a/blueprintcompiler/language/common.py +++ b/blueprintcompiler/language/common.py @@ -46,7 +46,14 @@ from ..gir import ( IntType, StringType, ) -from ..lsp_utils import Completion, CompletionItemKind, SemanticToken, SemanticTokenType +from ..lsp_utils import ( + Completion, + CompletionItemKind, + DocumentSymbol, + SemanticToken, + SemanticTokenType, + SymbolKind, +) from ..parse_tree import * OBJECT_CONTENT_HOOKS = AnyOf() diff --git a/blueprintcompiler/language/gobject_object.py b/blueprintcompiler/language/gobject_object.py index 1a42c0a..16c92e7 100644 --- a/blueprintcompiler/language/gobject_object.py +++ b/blueprintcompiler/language/gobject_object.py @@ -21,6 +21,9 @@ import typing as T from functools import cached_property +from blueprintcompiler.errors import T +from blueprintcompiler.lsp_utils import DocumentSymbol + from .common import * from .response_id import ExtResponse from .types import ClassName, ConcreteClassName @@ -59,8 +62,20 @@ class Object(AstNode): def signature(self) -> str: if self.id: return f"{self.class_name.gir_type.full_name} {self.id}" + elif t := self.class_name.gir_type: + return f"{t.full_name}" else: - return f"{self.class_name.gir_type.full_name}" + return f"{self.class_name.as_string}" + + @property + def document_symbol(self) -> T.Optional[DocumentSymbol]: + return DocumentSymbol( + self.class_name.as_string, + SymbolKind.Object, + self.range, + self.children[ClassName][0].range, + self.id, + ) @property def gir_class(self) -> GirType: diff --git a/blueprintcompiler/language/gobject_property.py b/blueprintcompiler/language/gobject_property.py index 68f8c6d..3d2ac5b 100644 --- a/blueprintcompiler/language/gobject_property.py +++ b/blueprintcompiler/language/gobject_property.py @@ -47,6 +47,21 @@ class Property(AstNode): else: return None + @property + def document_symbol(self) -> DocumentSymbol: + if isinstance(self.value, ObjectValue): + detail = None + else: + detail = self.value.range.text + + return DocumentSymbol( + self.name, + SymbolKind.Property, + self.range, + self.group.tokens["name"].range, + detail, + ) + @validate() def binding_valid(self): if ( diff --git a/blueprintcompiler/language/gobject_signal.py b/blueprintcompiler/language/gobject_signal.py index 721c443..063b2e8 100644 --- a/blueprintcompiler/language/gobject_signal.py +++ b/blueprintcompiler/language/gobject_signal.py @@ -51,12 +51,14 @@ class Signal(AstNode): ] ), "=>", + Mark("detail_start"), Optional(["$", UseLiteral("extern", True)]), UseIdent("handler").expected("the name of a function to handle the signal"), Match("(").expected("argument list"), Optional(UseIdent("object")).expected("object identifier"), Match(")").expected(), ZeroOrMore(SignalFlag), + Mark("detail_end"), ) @property @@ -105,6 +107,16 @@ class Signal(AstNode): def gir_class(self): return self.parent.parent.gir_class + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.full_name, + SymbolKind.Event, + self.range, + self.group.tokens["name"].range, + self.ranges["detail_start", "detail_end"].text, + ) + @validate("handler") def old_extern(self): if not self.tokens["extern"]: diff --git a/blueprintcompiler/language/gtk_a11y.py b/blueprintcompiler/language/gtk_a11y.py index 6a346ec..e9850f5 100644 --- a/blueprintcompiler/language/gtk_a11y.py +++ b/blueprintcompiler/language/gtk_a11y.py @@ -139,6 +139,16 @@ class A11yProperty(BaseAttribute): def value_type(self) -> ValueTypeCtx: return ValueTypeCtx(get_types(self.root.gir).get(self.tokens["name"])) + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.name, + SymbolKind.Field, + self.range, + self.group.tokens["name"].range, + self.value.range.text, + ) + @validate("name") def is_valid_property(self): types = get_types(self.root.gir) @@ -172,6 +182,15 @@ class ExtAccessibility(AstNode): def properties(self) -> T.List[A11yProperty]: return self.children[A11yProperty] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "accessibility", + SymbolKind.Struct, + self.range, + self.group.tokens["accessibility"].range, + ) + @validate("accessibility") def container_is_widget(self): validate_parent_type(self, "Gtk", "Widget", "accessibility properties") diff --git a/blueprintcompiler/language/gtk_combo_box_text.py b/blueprintcompiler/language/gtk_combo_box_text.py index e6c804e..4c6fda9 100644 --- a/blueprintcompiler/language/gtk_combo_box_text.py +++ b/blueprintcompiler/language/gtk_combo_box_text.py @@ -31,13 +31,23 @@ class Item(AstNode): ] @property - def name(self) -> str: + def name(self) -> T.Optional[str]: return self.tokens["name"] @property def value(self) -> StringValue: return self.children[StringValue][0] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.value.range.text, + SymbolKind.String, + self.range, + self.value.range, + self.name, + ) + @validate("name") def unique_in_parent(self): if self.name is not None: @@ -54,6 +64,15 @@ class ExtComboBoxItems(AstNode): "]", ] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "items", + SymbolKind.Array, + self.range, + self.group.tokens["items"].range, + ) + @validate("items") def container_is_combo_box_text(self): validate_parent_type(self, "Gtk", "ComboBoxText", "combo box items") diff --git a/blueprintcompiler/language/gtk_file_filter.py b/blueprintcompiler/language/gtk_file_filter.py index 53cd102..1be83e8 100644 --- a/blueprintcompiler/language/gtk_file_filter.py +++ b/blueprintcompiler/language/gtk_file_filter.py @@ -23,6 +23,15 @@ from .gobject_object import ObjectContent, validate_parent_type class Filters(AstNode): + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.tokens["tag_name"], + SymbolKind.Array, + self.range, + self.group.tokens[self.tokens["tag_name"]].range, + ) + @validate() def container_is_file_filter(self): validate_parent_type(self, "Gtk", "FileFilter", "file filter properties") @@ -46,6 +55,15 @@ class FilterString(AstNode): def item(self) -> str: return self.tokens["name"] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.item, + SymbolKind.String, + self.range, + self.group.tokens["name"].range, + ) + @validate() def unique_in_parent(self): self.validate_unique_in_parent( diff --git a/blueprintcompiler/language/gtk_layout.py b/blueprintcompiler/language/gtk_layout.py index 7632c7a..892e0c6 100644 --- a/blueprintcompiler/language/gtk_layout.py +++ b/blueprintcompiler/language/gtk_layout.py @@ -36,6 +36,16 @@ class LayoutProperty(AstNode): def value(self) -> Value: return self.children[Value][0] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.name, + SymbolKind.Field, + self.range, + self.group.tokens["name"].range, + self.value.range.text, + ) + @context(ValueTypeCtx) def value_type(self) -> ValueTypeCtx: # there isn't really a way to validate these @@ -56,6 +66,15 @@ class ExtLayout(AstNode): Until(LayoutProperty, "}"), ) + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "layout", + SymbolKind.Struct, + self.range, + self.group.tokens["layout"].range, + ) + @validate("layout") def container_is_widget(self): validate_parent_type(self, "Gtk", "Widget", "layout properties") diff --git a/blueprintcompiler/language/gtk_list_item_factory.py b/blueprintcompiler/language/gtk_list_item_factory.py index ba2a27f..7d05422 100644 --- a/blueprintcompiler/language/gtk_list_item_factory.py +++ b/blueprintcompiler/language/gtk_list_item_factory.py @@ -1,5 +1,9 @@ +import typing as T + +from blueprintcompiler.errors import T +from blueprintcompiler.lsp_utils import DocumentSymbol + from ..ast_utils import AstNode, validate -from ..parse_tree import Keyword from .common import * from .contexts import ScopeCtx from .gobject_object import ObjectContent, validate_parent_type @@ -17,6 +21,15 @@ class ExtListItemFactory(AstNode): def signature(self) -> str: return f"template {self.gir_class.full_name}" + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.signature, + SymbolKind.Object, + self.range, + self.group.tokens["id"].range, + ) + @property def type_name(self) -> T.Optional[TypeName]: if len(self.children[TypeName]) == 1: diff --git a/blueprintcompiler/language/gtk_menu.py b/blueprintcompiler/language/gtk_menu.py index 824ec5c..e1bbd57 100644 --- a/blueprintcompiler/language/gtk_menu.py +++ b/blueprintcompiler/language/gtk_menu.py @@ -42,6 +42,16 @@ class Menu(AstNode): else: return "Gio.Menu" + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.tokens["tag"], + SymbolKind.Object, + self.range, + self.group.tokens[self.tokens["tag"]].range, + self.id, + ) + @property def tag(self) -> str: return self.tokens["tag"] @@ -72,6 +82,18 @@ class MenuAttribute(AstNode): def value(self) -> StringValue: return self.children[StringValue][0] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.name, + SymbolKind.Field, + self.range, + self.group.tokens["name"].range + if self.group.tokens["name"] + else self.range, + self.value.range.text, + ) + @context(ValueTypeCtx) def value_type(self) -> ValueTypeCtx: return ValueTypeCtx(None) @@ -98,7 +120,7 @@ menu_attribute = Group( menu_section = Group( Menu, [ - "section", + Keyword("section"), UseLiteral("tag", "section"), Optional(UseIdent("id")), Match("{").expected(), @@ -109,7 +131,7 @@ menu_section = Group( menu_submenu = Group( Menu, [ - "submenu", + Keyword("submenu"), UseLiteral("tag", "submenu"), Optional(UseIdent("id")), Match("{").expected(), @@ -120,7 +142,7 @@ menu_submenu = Group( menu_item = Group( Menu, [ - "item", + Keyword("item"), UseLiteral("tag", "item"), Match("{").expected(), Until(menu_attribute, "}"), @@ -130,7 +152,7 @@ menu_item = Group( menu_item_shorthand = Group( Menu, [ - "item", + Keyword("item"), UseLiteral("tag", "item"), "(", Group( diff --git a/blueprintcompiler/language/gtk_scale.py b/blueprintcompiler/language/gtk_scale.py index 18452e1..504e290 100644 --- a/blueprintcompiler/language/gtk_scale.py +++ b/blueprintcompiler/language/gtk_scale.py @@ -58,6 +58,16 @@ class ExtScaleMark(AstNode): else: return None + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + str(self.value), + SymbolKind.Field, + self.range, + self.group.tokens["mark"].range, + self.label.string if self.label else None, + ) + @docs("position") def position_docs(self) -> T.Optional[str]: if member := self.root.gir.get_type("PositionType", "Gtk").members.get( @@ -88,6 +98,15 @@ class ExtScaleMarks(AstNode): def marks(self) -> T.List[ExtScaleMark]: return self.children + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "marks", + SymbolKind.Array, + self.range, + self.group.tokens["marks"].range, + ) + @validate("marks") def container_is_size_group(self): validate_parent_type(self, "Gtk", "Scale", "scale marks") diff --git a/blueprintcompiler/language/gtk_size_group.py b/blueprintcompiler/language/gtk_size_group.py index 2a10a35..60af861 100644 --- a/blueprintcompiler/language/gtk_size_group.py +++ b/blueprintcompiler/language/gtk_size_group.py @@ -30,6 +30,15 @@ class Widget(AstNode): def name(self) -> str: return self.tokens["name"] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.name, + SymbolKind.Field, + self.range, + self.group.tokens["name"].range, + ) + @validate("name") def obj_widget(self): object = self.context[ScopeCtx].objects.get(self.tokens["name"]) @@ -62,6 +71,15 @@ class ExtSizeGroupWidgets(AstNode): "]", ] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "widgets", + SymbolKind.Array, + self.range, + self.group.tokens["widgets"].range, + ) + @validate("widgets") def container_is_size_group(self): validate_parent_type(self, "Gtk", "SizeGroup", "size group properties") diff --git a/blueprintcompiler/language/gtk_string_list.py b/blueprintcompiler/language/gtk_string_list.py index 455960e..37a46ec 100644 --- a/blueprintcompiler/language/gtk_string_list.py +++ b/blueprintcompiler/language/gtk_string_list.py @@ -30,6 +30,15 @@ class Item(AstNode): def child(self) -> StringValue: return self.children[StringValue][0] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.child.range.text, + SymbolKind.String, + self.range, + self.range, + ) + class ExtStringListStrings(AstNode): grammar = [ @@ -39,6 +48,15 @@ class ExtStringListStrings(AstNode): "]", ] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "strings", + SymbolKind.Array, + self.range, + self.group.tokens["strings"].range, + ) + @validate("items") def container_is_string_list(self): validate_parent_type(self, "Gtk", "StringList", "StringList items") diff --git a/blueprintcompiler/language/gtk_styles.py b/blueprintcompiler/language/gtk_styles.py index 8152b82..26c0e74 100644 --- a/blueprintcompiler/language/gtk_styles.py +++ b/blueprintcompiler/language/gtk_styles.py @@ -29,6 +29,15 @@ class StyleClass(AstNode): def name(self) -> str: return self.tokens["name"] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.name, + SymbolKind.String, + self.range, + self.range, + ) + @validate("name") def unique_in_parent(self): self.validate_unique_in_parent( @@ -44,6 +53,15 @@ class ExtStyles(AstNode): "]", ] + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + "styles", + SymbolKind.Array, + self.range, + self.group.tokens["styles"].range, + ) + @validate("styles") def container_is_widget(self): validate_parent_type(self, "Gtk", "Widget", "style classes") diff --git a/blueprintcompiler/language/gtkbuilder_template.py b/blueprintcompiler/language/gtkbuilder_template.py index 7af5cc8..e1e2131 100644 --- a/blueprintcompiler/language/gtkbuilder_template.py +++ b/blueprintcompiler/language/gtkbuilder_template.py @@ -46,10 +46,19 @@ class Template(Object): @property def signature(self) -> str: - if self.parent_type: - return f"template {self.gir_class.full_name} : {self.parent_type.gir_type.full_name}" + if self.parent_type and self.parent_type.gir_type: + return f"template {self.class_name.as_string} : {self.parent_type.gir_type.full_name}" else: - return f"template {self.gir_class.full_name}" + return f"template {self.class_name.as_string}" + + @property + def document_symbol(self) -> DocumentSymbol: + return DocumentSymbol( + self.signature, + SymbolKind.Object, + self.range, + self.group.tokens["id"].range, + ) @property def gir_class(self) -> GirType: diff --git a/blueprintcompiler/lsp.py b/blueprintcompiler/lsp.py index 54a36c8..ec161a8 100644 --- a/blueprintcompiler/lsp.py +++ b/blueprintcompiler/lsp.py @@ -203,6 +203,7 @@ class LanguageServer: "completionProvider": {}, "codeActionProvider": {}, "hoverProvider": True, + "documentSymbolProvider": True, }, "serverInfo": { "name": "Blueprint", @@ -363,6 +364,31 @@ class LanguageServer: self._send_response(id, actions) + @command("textDocument/documentSymbol") + def document_symbols(self, id, params): + open_file = self._open_files[params["textDocument"]["uri"]] + symbols = open_file.ast.get_document_symbols() + + def to_json(symbol: DocumentSymbol): + result = { + "name": symbol.name, + "kind": symbol.kind, + "range": utils.idxs_to_range( + symbol.range.start, symbol.range.end, open_file.text + ), + "selectionRange": utils.idxs_to_range( + symbol.selection_range.start, + symbol.selection_range.end, + open_file.text, + ), + "children": [to_json(child) for child in symbol.children], + } + if symbol.detail is not None: + result["detail"] = symbol.detail + return result + + self._send_response(id, [to_json(symbol) for symbol in symbols]) + def _send_file_updates(self, open_file: OpenFile): self._send_notification( "textDocument/publishDiagnostics", diff --git a/blueprintcompiler/lsp_utils.py b/blueprintcompiler/lsp_utils.py index 2d1072e..2225ba2 100644 --- a/blueprintcompiler/lsp_utils.py +++ b/blueprintcompiler/lsp_utils.py @@ -20,9 +20,10 @@ import enum import typing as T -from dataclasses import dataclass +from dataclasses import dataclass, field from .errors import * +from .tokenizer import Range from .utils import * @@ -129,3 +130,42 @@ class SemanticToken: start: int end: int type: SemanticTokenType + + +class SymbolKind(enum.IntEnum): + File = 1 + Module = 2 + Namespace = 3 + Package = 4 + Class = 5 + Method = 6 + Property = 7 + Field = 8 + Constructor = 9 + Enum = 10 + Interface = 11 + Function = 12 + Variable = 13 + Constant = 14 + String = 15 + Number = 16 + Boolean = 17 + Array = 18 + Object = 19 + Key = 20 + Null = 21 + EnumMember = 22 + Struct = 23 + Event = 24 + Operator = 25 + TypeParameter = 26 + + +@dataclass +class DocumentSymbol: + name: str + kind: SymbolKind + range: Range + selection_range: Range + detail: T.Optional[str] = None + children: T.List["DocumentSymbol"] = field(default_factory=list) diff --git a/blueprintcompiler/parse_tree.py b/blueprintcompiler/parse_tree.py index 3ddac69..b29bd96 100644 --- a/blueprintcompiler/parse_tree.py +++ b/blueprintcompiler/parse_tree.py @@ -20,7 +20,6 @@ """ Utilities for parsing an AST from a token stream. """ import typing as T -from collections import defaultdict from enum import Enum from .ast_utils import AstNode @@ -31,7 +30,7 @@ from .errors import ( UnexpectedTokenError, assert_true, ) -from .tokenizer import Token, TokenType +from .tokenizer import Range, Token, TokenType SKIP_TOKENS = [TokenType.COMMENT, TokenType.WHITESPACE] @@ -63,14 +62,16 @@ class ParseGroup: be converted to AST nodes by passing the children and key=value pairs to the AST node constructor.""" - def __init__(self, ast_type: T.Type[AstNode], start: int): + def __init__(self, ast_type: T.Type[AstNode], start: int, text: str): self.ast_type = ast_type self.children: T.List[ParseGroup] = [] self.keys: T.Dict[str, T.Any] = {} self.tokens: T.Dict[str, T.Optional[Token]] = {} + self.ranges: T.Dict[str, Range] = {} self.start = start self.end: T.Optional[int] = None self.incomplete = False + self.text = text def add_child(self, child: "ParseGroup"): self.children.append(child) @@ -81,6 +82,10 @@ class ParseGroup: self.keys[key] = val self.tokens[key] = token + def set_range(self, key: str, range: Range): + assert_true(key not in self.ranges) + self.ranges[key] = range + def to_ast(self): """Creates an AST node from the match group.""" children = [child.to_ast() for child in self.children] @@ -104,8 +109,9 @@ class ParseGroup: class ParseContext: """Contains the state of the parser.""" - def __init__(self, tokens: T.List[Token], index=0): + def __init__(self, tokens: T.List[Token], text: str, index=0): self.tokens = tokens + self.text = text self.binding_power = 0 self.index = index @@ -113,6 +119,7 @@ class ParseContext: self.group: T.Optional[ParseGroup] = None self.group_keys: T.Dict[str, T.Tuple[T.Any, T.Optional[Token]]] = {} self.group_children: T.List[ParseGroup] = [] + self.group_ranges: T.Dict[str, Range] = {} self.last_group: T.Optional[ParseGroup] = None self.group_incomplete = False @@ -124,7 +131,7 @@ class ParseContext: context will be used to parse one node. If parsing is successful, the new context will be applied to "self". If parsing fails, the new context will be discarded.""" - ctx = ParseContext(self.tokens, self.index) + ctx = ParseContext(self.tokens, self.text, self.index) ctx.errors = self.errors ctx.warnings = self.warnings ctx.binding_power = self.binding_power @@ -140,6 +147,8 @@ class ParseContext: other.group.set_val(key, val, token) for child in other.group_children: other.group.add_child(child) + for key, range in other.group_ranges.items(): + other.group.set_range(key, range) other.group.end = other.tokens[other.index - 1].end other.group.incomplete = other.group_incomplete self.group_children.append(other.group) @@ -148,6 +157,7 @@ class ParseContext: # its matched values self.group_keys = {**self.group_keys, **other.group_keys} self.group_children += other.group_children + self.group_ranges = {**self.group_ranges, **other.group_ranges} self.group_incomplete |= other.group_incomplete self.index = other.index @@ -161,13 +171,19 @@ class ParseContext: def start_group(self, ast_type: T.Type[AstNode]): """Sets this context to have its own match group.""" assert_true(self.group is None) - self.group = ParseGroup(ast_type, self.tokens[self.index].start) + self.group = ParseGroup(ast_type, self.tokens[self.index].start, self.text) def set_group_val(self, key: str, value: T.Any, token: T.Optional[Token]): """Sets a matched key=value pair on the current match group.""" assert_true(key not in self.group_keys) self.group_keys[key] = (value, token) + def set_mark(self, key: str): + """Sets a zero-length range on the current match group at the current position.""" + self.group_ranges[key] = Range( + self.tokens[self.index].start, self.tokens[self.index].start, self.text + ) + def set_group_incomplete(self): """Marks the current match group as incomplete (it could not be fully parsed, but the parser recovered).""" @@ -604,6 +620,15 @@ class Keyword(ParseNode): return str(token) == self.kw +class Mark(ParseNode): + def __init__(self, key: str): + self.key = key + + def _parse(self, ctx: ParseContext): + ctx.set_mark(self.key) + return True + + def to_parse_node(value) -> ParseNode: if isinstance(value, str): return Match(value) diff --git a/blueprintcompiler/parser.py b/blueprintcompiler/parser.py index 8b50de5..a9cc0ae 100644 --- a/blueprintcompiler/parser.py +++ b/blueprintcompiler/parser.py @@ -30,7 +30,8 @@ def parse( """Parses a list of tokens into an abstract syntax tree.""" try: - ctx = ParseContext(tokens) + original_text = tokens[0].string if len(tokens) else "" + ctx = ParseContext(tokens, original_text) AnyOf(UI).parse(ctx) ast_node = ctx.last_group.to_ast() if ctx.last_group else None diff --git a/blueprintcompiler/tokenizer.py b/blueprintcompiler/tokenizer.py index bde8dd1..64abf0f 100644 --- a/blueprintcompiler/tokenizer.py +++ b/blueprintcompiler/tokenizer.py @@ -20,6 +20,7 @@ import re import typing as T +from dataclasses import dataclass from enum import Enum from .errors import CompileError, CompilerBugError @@ -62,6 +63,10 @@ class Token: def __str__(self) -> str: return self.string[self.start : self.end] + @property + def range(self) -> "Range": + return Range(self.start, self.end, self.string) + def get_number(self) -> T.Union[int, float]: if self.type != TokenType.NUMBER: raise CompilerBugError() @@ -103,3 +108,22 @@ def _tokenize(ui_ml: str): def tokenize(data: str) -> T.List[Token]: return list(_tokenize(data)) + + +@dataclass +class Range: + start: int + end: int + original_text: str + + @property + def text(self) -> str: + return self.original_text[self.start : self.end] + + @staticmethod + def join(a: T.Optional["Range"], b: T.Optional["Range"]) -> T.Optional["Range"]: + if a is None: + return b + if b is None: + return a + return Range(min(a.start, b.start), max(a.end, b.end), a.original_text) diff --git a/tests/test_samples.py b/tests/test_samples.py index bacca80..9ad8cc0 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -40,13 +40,12 @@ from blueprintcompiler.tokenizer import Token, TokenType, tokenize class TestSamples(unittest.TestCase): - def assert_docs_dont_crash(self, text, ast): + def assert_ast_doesnt_crash(self, text, tokens, ast): for i in range(len(text)): ast.get_docs(i) - - def assert_completions_dont_crash(self, text, ast, tokens): for i in range(len(text)): list(complete(ast, tokens, i)) + ast.get_document_symbols() def assert_sample(self, name, skip_run=False): print(f'assert_sample("{name}", skip_run={skip_run})') @@ -79,8 +78,7 @@ class TestSamples(unittest.TestCase): print("\n".join(diff)) raise AssertionError() - self.assert_docs_dont_crash(blueprint, ast) - self.assert_completions_dont_crash(blueprint, ast, tokens) + self.assert_ast_doesnt_crash(blueprint, tokens, ast) except PrintableError as e: # pragma: no cover e.pretty_print(name + ".blp", blueprint) raise AssertionError() @@ -105,8 +103,7 @@ class TestSamples(unittest.TestCase): ast, errors, warnings = parser.parse(tokens) if ast is not None: - self.assert_docs_dont_crash(blueprint, ast) - self.assert_completions_dont_crash(blueprint, ast, tokens) + self.assert_ast_doesnt_crash(blueprint, tokens, ast) if errors: raise errors From 62f74178f7c9168d578c5295190a0976eed43e85 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 18:40:05 -0500 Subject: [PATCH 2/7] lsp: Implement "go to definition" --- blueprintcompiler/ast_utils.py | 14 ++++++++++---- blueprintcompiler/language/common.py | 1 + blueprintcompiler/language/values.py | 10 ++++++++++ blueprintcompiler/lsp.py | 16 ++++++++++++++++ blueprintcompiler/lsp_utils.py | 15 +++++++++++++++ blueprintcompiler/parse_tree.py | 2 ++ blueprintcompiler/tokenizer.py | 10 ++++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) diff --git a/blueprintcompiler/ast_utils.py b/blueprintcompiler/ast_utils.py index 59331f9..81958aa 100644 --- a/blueprintcompiler/ast_utils.py +++ b/blueprintcompiler/ast_utils.py @@ -22,7 +22,7 @@ from collections import ChainMap, defaultdict from functools import cached_property from .errors import * -from .lsp_utils import DocumentSymbol, SemanticToken +from .lsp_utils import DocumentSymbol, SemanticToken, LocationLink from .tokenizer import Range TType = T.TypeVar("TType") @@ -185,9 +185,8 @@ class AstNode: return getattr(self, name) for child in self.children: - if child.group.start <= idx < child.group.end: - docs = child.get_docs(idx) - if docs is not None: + if idx in child.range: + if docs := child.get_docs(idx): return docs return None @@ -196,6 +195,13 @@ class AstNode: for child in self.children: yield from child.get_semantic_tokens() + def get_reference(self, idx: int) -> T.Optional[LocationLink]: + for child in self.children: + if idx in child.range: + if ref := child.get_reference(idx): + return ref + return None + @property def document_symbol(self) -> T.Optional[DocumentSymbol]: return None diff --git a/blueprintcompiler/language/common.py b/blueprintcompiler/language/common.py index e1bfa36..8a6ce9b 100644 --- a/blueprintcompiler/language/common.py +++ b/blueprintcompiler/language/common.py @@ -50,6 +50,7 @@ from ..lsp_utils import ( Completion, CompletionItemKind, DocumentSymbol, + LocationLink, SemanticToken, SemanticTokenType, SymbolKind, diff --git a/blueprintcompiler/language/values.py b/blueprintcompiler/language/values.py index 888d6a1..b9d719f 100644 --- a/blueprintcompiler/language/values.py +++ b/blueprintcompiler/language/values.py @@ -339,6 +339,16 @@ class IdentLiteral(AstNode): token = self.group.tokens["value"] yield SemanticToken(token.start, token.end, SemanticTokenType.EnumMember) + def get_reference(self, _idx: int) -> T.Optional[LocationLink]: + ref = self.context[ScopeCtx].objects.get(self.ident) + if ref is None and self.root.is_legacy_template(self.ident): + ref = self.root.template + + if ref: + return LocationLink(self.range, ref.range, ref.ranges["id"]) + else: + return None + class Literal(AstNode): grammar = AnyOf( diff --git a/blueprintcompiler/lsp.py b/blueprintcompiler/lsp.py index ec161a8..a7e5f9b 100644 --- a/blueprintcompiler/lsp.py +++ b/blueprintcompiler/lsp.py @@ -204,6 +204,7 @@ class LanguageServer: "codeActionProvider": {}, "hoverProvider": True, "documentSymbolProvider": True, + "definitionProvider": True, }, "serverInfo": { "name": "Blueprint", @@ -389,6 +390,21 @@ class LanguageServer: self._send_response(id, [to_json(symbol) for symbol in symbols]) + @command("textDocument/definition") + def definition(self, id, params): + open_file = self._open_files[params["textDocument"]["uri"]] + idx = utils.pos_to_idx( + params["position"]["line"], params["position"]["character"], open_file.text + ) + definition = open_file.ast.get_reference(idx) + if definition is None: + self._send_response(id, None) + else: + self._send_response( + id, + definition.to_json(open_file.uri), + ) + def _send_file_updates(self, open_file: OpenFile): self._send_notification( "textDocument/publishDiagnostics", diff --git a/blueprintcompiler/lsp_utils.py b/blueprintcompiler/lsp_utils.py index 2225ba2..9da8461 100644 --- a/blueprintcompiler/lsp_utils.py +++ b/blueprintcompiler/lsp_utils.py @@ -169,3 +169,18 @@ class DocumentSymbol: selection_range: Range detail: T.Optional[str] = None children: T.List["DocumentSymbol"] = field(default_factory=list) + + +@dataclass +class LocationLink: + origin_selection_range: Range + target_range: Range + target_selection_range: Range + + def to_json(self, target_uri: str): + return { + "originSelectionRange": self.origin_selection_range.to_json(), + "targetUri": target_uri, + "targetRange": self.target_range.to_json(), + "targetSelectionRange": self.target_selection_range.to_json(), + } diff --git a/blueprintcompiler/parse_tree.py b/blueprintcompiler/parse_tree.py index b29bd96..a8efddb 100644 --- a/blueprintcompiler/parse_tree.py +++ b/blueprintcompiler/parse_tree.py @@ -81,6 +81,8 @@ class ParseGroup: self.keys[key] = val self.tokens[key] = token + if token: + self.set_range(key, token.range) def set_range(self, key: str, range: Range): assert_true(key not in self.ranges) diff --git a/blueprintcompiler/tokenizer.py b/blueprintcompiler/tokenizer.py index 64abf0f..e1066ac 100644 --- a/blueprintcompiler/tokenizer.py +++ b/blueprintcompiler/tokenizer.py @@ -24,6 +24,7 @@ from dataclasses import dataclass from enum import Enum from .errors import CompileError, CompilerBugError +from . import utils class TokenType(Enum): @@ -127,3 +128,12 @@ class Range: if b is None: return a return Range(min(a.start, b.start), max(a.end, b.end), a.original_text) + + def __contains__(self, other: T.Union[int, "Range"]) -> bool: + if isinstance(other, int): + return self.start <= other <= self.end + else: + return self.start <= other.start and self.end >= other.end + + def to_json(self): + return utils.idxs_to_range(self.start, self.end, self.original_text) From a9cb423b3b387c7bb3687b838118c256d7b86b88 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 18:52:18 -0500 Subject: [PATCH 3/7] lsp: Add missing semantic highlight --- blueprintcompiler/language/gtk_scale.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/blueprintcompiler/language/gtk_scale.py b/blueprintcompiler/language/gtk_scale.py index 504e290..a2c9168 100644 --- a/blueprintcompiler/language/gtk_scale.py +++ b/blueprintcompiler/language/gtk_scale.py @@ -68,6 +68,14 @@ class ExtScaleMark(AstNode): self.label.string if self.label else None, ) + def get_semantic_tokens(self) -> T.Iterator[SemanticToken]: + if range := self.ranges["position"]: + yield SemanticToken( + range.start, + range.end, + SemanticTokenType.EnumMember, + ) + @docs("position") def position_docs(self) -> T.Optional[str]: if member := self.root.gir.get_type("PositionType", "Gtk").members.get( From 56274d7c1faa430cb8747009d974fec4d1b4a321 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 18:54:58 -0500 Subject: [PATCH 4/7] completions: Fix signal completion --- blueprintcompiler/completions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blueprintcompiler/completions.py b/blueprintcompiler/completions.py index b189bf1..56db53a 100644 --- a/blueprintcompiler/completions.py +++ b/blueprintcompiler/completions.py @@ -158,7 +158,7 @@ def signal_completer(ast_node, match_variables): yield Completion( signal, CompletionItemKind.Property, - snippet=f"{signal} => ${{1:{name}_{signal.replace('-', '_')}}}()$0;", + snippet=f"{signal} => \$${{1:{name}_{signal.replace('-', '_')}}}()$0;", ) From 3bcc9f4cbd2158fa7b90fb3280dbb1b7e03ddc33 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 20:01:41 -0500 Subject: [PATCH 5/7] Use the new Range class in more places --- blueprintcompiler/ast_utils.py | 35 ++++++-------- blueprintcompiler/errors.py | 21 ++++---- blueprintcompiler/language/contexts.py | 3 +- blueprintcompiler/language/ui.py | 3 +- blueprintcompiler/lsp.py | 67 ++++++++++++-------------- blueprintcompiler/main.py | 6 +-- blueprintcompiler/parse_tree.py | 19 +++++--- blueprintcompiler/parser.py | 2 +- blueprintcompiler/tokenizer.py | 19 ++++++-- tests/test_samples.py | 6 +-- 10 files changed, 91 insertions(+), 90 deletions(-) diff --git a/blueprintcompiler/ast_utils.py b/blueprintcompiler/ast_utils.py index 81958aa..56501e7 100644 --- a/blueprintcompiler/ast_utils.py +++ b/blueprintcompiler/ast_utils.py @@ -229,20 +229,23 @@ class AstNode: error, references=[ ErrorReference( - child.group.start, - child.group.end, + child.range, "previous declaration was here", ) ], ) -def validate(token_name=None, end_token_name=None, skip_incomplete=False): +def validate( + token_name: T.Optional[str] = None, + end_token_name: T.Optional[str] = None, + skip_incomplete: bool = False, +): """Decorator for functions that validate an AST node. Exceptions raised during validation are marked with range information from the tokens.""" def decorator(func): - def inner(self): + def inner(self: AstNode): if skip_incomplete and self.incomplete: return @@ -254,22 +257,14 @@ def validate(token_name=None, end_token_name=None, skip_incomplete=False): if self.incomplete: return - # This mess of code sets the error's start and end positions - # from the tokens passed to the decorator, if they have not - # already been set - if e.start is None: - if token := self.group.tokens.get(token_name): - e.start = token.start - else: - e.start = self.group.start - - if e.end is None: - if token := self.group.tokens.get(end_token_name): - e.end = token.end - elif token := self.group.tokens.get(token_name): - e.end = token.end - else: - e.end = self.group.end + if e.range is None: + e.range = ( + Range.join( + self.ranges[token_name], + self.ranges[end_token_name], + ) + or self.range + ) # Re-raise the exception raise e diff --git a/blueprintcompiler/errors.py b/blueprintcompiler/errors.py index e89ec31..773122a 100644 --- a/blueprintcompiler/errors.py +++ b/blueprintcompiler/errors.py @@ -23,6 +23,7 @@ import typing as T from dataclasses import dataclass from . import utils +from .tokenizer import Range from .utils import Colors @@ -36,8 +37,7 @@ class PrintableError(Exception): @dataclass class ErrorReference: - start: int - end: int + range: Range message: str @@ -50,8 +50,7 @@ class CompileError(PrintableError): def __init__( self, message: str, - start: T.Optional[int] = None, - end: T.Optional[int] = None, + range: T.Optional[Range] = None, did_you_mean: T.Optional[T.Tuple[str, T.List[str]]] = None, hints: T.Optional[T.List[str]] = None, actions: T.Optional[T.List["CodeAction"]] = None, @@ -61,8 +60,7 @@ class CompileError(PrintableError): super().__init__(message) self.message = message - self.start = start - self.end = end + self.range = range self.hints = hints or [] self.actions = actions or [] self.references = references or [] @@ -92,9 +90,9 @@ class CompileError(PrintableError): self.hint("Are your dependencies up to date?") def pretty_print(self, filename: str, code: str, stream=sys.stdout) -> None: - assert self.start is not None + assert self.range is not None - line_num, col_num = utils.idx_to_pos(self.start + 1, code) + line_num, col_num = utils.idx_to_pos(self.range.start + 1, code) line = code.splitlines(True)[line_num] # Display 1-based line numbers @@ -110,7 +108,7 @@ at {filename} line {line_num} column {col_num}: stream.write(f"{Colors.FAINT}hint: {hint}{Colors.CLEAR}\n") for ref in self.references: - line_num, col_num = utils.idx_to_pos(ref.start + 1, code) + line_num, col_num = utils.idx_to_pos(ref.range.start + 1, code) line = code.splitlines(True)[line_num] line_num += 1 @@ -138,14 +136,15 @@ class UpgradeWarning(CompileWarning): class UnexpectedTokenError(CompileError): - def __init__(self, start, end) -> None: - super().__init__("Unexpected tokens", start, end) + def __init__(self, range: Range) -> None: + super().__init__("Unexpected tokens", range) @dataclass class CodeAction: title: str replace_with: str + edit_range: T.Optional[Range] = None class MultipleErrors(PrintableError): diff --git a/blueprintcompiler/language/contexts.py b/blueprintcompiler/language/contexts.py index 2f8e22e..c5e97b3 100644 --- a/blueprintcompiler/language/contexts.py +++ b/blueprintcompiler/language/contexts.py @@ -70,8 +70,7 @@ class ScopeCtx: ): raise CompileError( f"Duplicate object ID '{obj.tokens['id']}'", - token.start, - token.end, + token.range, ) passed[obj.tokens["id"]] = obj diff --git a/blueprintcompiler/language/ui.py b/blueprintcompiler/language/ui.py index 1b7e6e9..3ce23da 100644 --- a/blueprintcompiler/language/ui.py +++ b/blueprintcompiler/language/ui.py @@ -62,8 +62,7 @@ class UI(AstNode): else: gir_ctx.not_found_namespaces.add(i.namespace) except CompileError as e: - e.start = i.group.tokens["namespace"].start - e.end = i.group.tokens["version"].end + e.range = i.range self._gir_errors.append(e) return gir_ctx diff --git a/blueprintcompiler/lsp.py b/blueprintcompiler/lsp.py index a7e5f9b..19c02e5 100644 --- a/blueprintcompiler/lsp.py +++ b/blueprintcompiler/lsp.py @@ -24,10 +24,12 @@ import traceback import typing as T from . import decompiler, parser, tokenizer, utils, xml_reader +from .ast_utils import AstNode from .completions import complete -from .errors import CompileError, MultipleErrors, PrintableError +from .errors import CompileError, MultipleErrors from .lsp_utils import * from .outputs.xml import XmlOutput +from .tokenizer import Token def printerr(*args, **kwargs): @@ -43,16 +45,16 @@ def command(json_method: str): class OpenFile: - def __init__(self, uri: str, text: str, version: int): + def __init__(self, uri: str, text: str, version: int) -> None: self.uri = uri self.text = text self.version = version - self.ast = None - self.tokens = None + self.ast: T.Optional[AstNode] = None + self.tokens: T.Optional[list[Token]] = None self._update() - def apply_changes(self, changes): + def apply_changes(self, changes) -> None: for change in changes: if "range" not in change: self.text = change["text"] @@ -70,8 +72,8 @@ class OpenFile: self.text = self.text[:start] + change["text"] + self.text[end:] self._update() - def _update(self): - self.diagnostics = [] + def _update(self) -> None: + self.diagnostics: list[CompileError] = [] try: self.tokens = tokenizer.tokenize(self.text) self.ast, errors, warnings = parser.parse(self.tokens) @@ -327,14 +329,17 @@ class LanguageServer: def code_actions(self, id, params): open_file = self._open_files[params["textDocument"]["uri"]] - range_start = utils.pos_to_idx( - params["range"]["start"]["line"], - params["range"]["start"]["character"], - open_file.text, - ) - range_end = utils.pos_to_idx( - params["range"]["end"]["line"], - params["range"]["end"]["character"], + range = Range( + utils.pos_to_idx( + params["range"]["start"]["line"], + params["range"]["start"]["character"], + open_file.text, + ), + utils.pos_to_idx( + params["range"]["end"]["line"], + params["range"]["end"]["character"], + open_file.text, + ), open_file.text, ) @@ -342,16 +347,14 @@ class LanguageServer: { "title": action.title, "kind": "quickfix", - "diagnostics": [ - self._create_diagnostic(open_file.text, open_file.uri, diagnostic) - ], + "diagnostics": [self._create_diagnostic(open_file.uri, diagnostic)], "edit": { "changes": { open_file.uri: [ { - "range": utils.idxs_to_range( - diagnostic.start, diagnostic.end, open_file.text - ), + "range": action.edit_range.to_json() + if action.edit_range + else diagnostic.range.to_json(), "newText": action.replace_with, } ] @@ -359,7 +362,7 @@ class LanguageServer: }, } for diagnostic in open_file.diagnostics - if not (diagnostic.end < range_start or diagnostic.start > range_end) + if range.overlaps(diagnostic.range) for action in diagnostic.actions ] @@ -374,14 +377,8 @@ class LanguageServer: result = { "name": symbol.name, "kind": symbol.kind, - "range": utils.idxs_to_range( - symbol.range.start, symbol.range.end, open_file.text - ), - "selectionRange": utils.idxs_to_range( - symbol.selection_range.start, - symbol.selection_range.end, - open_file.text, - ), + "range": symbol.range.to_json(), + "selectionRange": symbol.selection_range.to_json(), "children": [to_json(child) for child in symbol.children], } if symbol.detail is not None: @@ -411,22 +408,22 @@ class LanguageServer: { "uri": open_file.uri, "diagnostics": [ - self._create_diagnostic(open_file.text, open_file.uri, err) + self._create_diagnostic(open_file.uri, err) for err in open_file.diagnostics ], }, ) - def _create_diagnostic(self, text: str, uri: str, err: CompileError): + def _create_diagnostic(self, uri: str, err: CompileError): message = err.message - assert err.start is not None and err.end is not None + assert err.range is not None for hint in err.hints: message += "\nhint: " + hint result = { - "range": utils.idxs_to_range(err.start, err.end, text), + "range": err.range.to_json(), "message": message, "severity": DiagnosticSeverity.Warning if isinstance(err, CompileWarning) @@ -441,7 +438,7 @@ class LanguageServer: { "location": { "uri": uri, - "range": utils.idxs_to_range(ref.start, ref.end, text), + "range": ref.range.to_json(), }, "message": ref.message, } diff --git a/blueprintcompiler/main.py b/blueprintcompiler/main.py index db9fb65..416db47 100644 --- a/blueprintcompiler/main.py +++ b/blueprintcompiler/main.py @@ -24,8 +24,8 @@ import os import sys import typing as T -from . import decompiler, interactive_port, parser, tokenizer -from .errors import CompilerBugError, MultipleErrors, PrintableError, report_bug +from . import interactive_port, parser, tokenizer +from .errors import CompilerBugError, CompileError, PrintableError, report_bug from .gir import add_typelib_search_path from .lsp import LanguageServer from .outputs import XmlOutput @@ -157,7 +157,7 @@ class BlueprintApp: def cmd_port(self, opts): interactive_port.run(opts) - def _compile(self, data: str) -> T.Tuple[str, T.List[PrintableError]]: + def _compile(self, data: str) -> T.Tuple[str, T.List[CompileError]]: tokens = tokenizer.tokenize(data) ast, errors, warnings = parser.parse(tokens) diff --git a/blueprintcompiler/parse_tree.py b/blueprintcompiler/parse_tree.py index a8efddb..8f3ef31 100644 --- a/blueprintcompiler/parse_tree.py +++ b/blueprintcompiler/parse_tree.py @@ -224,11 +224,11 @@ class ParseContext: if ( len(self.errors) and isinstance((err := self.errors[-1]), UnexpectedTokenError) - and err.end == start + and err.range.end == start ): - err.end = end + err.range.end = end else: - self.errors.append(UnexpectedTokenError(start, end)) + self.errors.append(UnexpectedTokenError(Range(start, end, self.text))) def is_eof(self) -> bool: return self.index >= len(self.tokens) or self.peek_token().type == TokenType.EOF @@ -281,10 +281,11 @@ class Err(ParseNode): start_idx = ctx.start while ctx.tokens[start_idx].type in SKIP_TOKENS: start_idx += 1 - start_token = ctx.tokens[start_idx] - end_token = ctx.tokens[ctx.index] - raise CompileError(self.message, start_token.start, end_token.end) + + raise CompileError( + self.message, Range(start_token.start, start_token.start, ctx.text) + ) return True @@ -324,7 +325,9 @@ class Fail(ParseNode): start_token = ctx.tokens[start_idx] end_token = ctx.tokens[ctx.index] - raise CompileError(self.message, start_token.start, end_token.end) + raise CompileError( + self.message, Range.join(start_token.range, end_token.range) + ) return True @@ -373,7 +376,7 @@ class Statement(ParseNode): token = ctx.peek_token() if str(token) != ";": - ctx.errors.append(CompileError("Expected `;`", token.start, token.end)) + ctx.errors.append(CompileError("Expected `;`", token.range)) else: ctx.next_token() return True diff --git a/blueprintcompiler/parser.py b/blueprintcompiler/parser.py index a9cc0ae..89e1533 100644 --- a/blueprintcompiler/parser.py +++ b/blueprintcompiler/parser.py @@ -26,7 +26,7 @@ from .tokenizer import TokenType def parse( tokens: T.List[Token], -) -> T.Tuple[T.Optional[UI], T.Optional[MultipleErrors], T.List[PrintableError]]: +) -> T.Tuple[T.Optional[UI], T.Optional[MultipleErrors], T.List[CompileError]]: """Parses a list of tokens into an abstract syntax tree.""" try: diff --git a/blueprintcompiler/tokenizer.py b/blueprintcompiler/tokenizer.py index e1066ac..1ab6def 100644 --- a/blueprintcompiler/tokenizer.py +++ b/blueprintcompiler/tokenizer.py @@ -23,7 +23,6 @@ import typing as T from dataclasses import dataclass from enum import Enum -from .errors import CompileError, CompilerBugError from . import utils @@ -69,6 +68,8 @@ class Token: return Range(self.start, self.end, self.string) def get_number(self) -> T.Union[int, float]: + from .errors import CompileError, CompilerBugError + if self.type != TokenType.NUMBER: raise CompilerBugError() @@ -81,12 +82,12 @@ class Token: else: return int(string) except: - raise CompileError( - f"{str(self)} is not a valid number literal", self.start, self.end - ) + raise CompileError(f"{str(self)} is not a valid number literal", self.range) def _tokenize(ui_ml: str): + from .errors import CompileError + i = 0 while i < len(ui_ml): matched = False @@ -101,7 +102,8 @@ def _tokenize(ui_ml: str): if not matched: raise CompileError( - "Could not determine what kind of syntax is meant here", i, i + "Could not determine what kind of syntax is meant here", + Range(i, i, ui_ml), ) yield Token(TokenType.EOF, i, i, ui_ml) @@ -117,6 +119,10 @@ class Range: end: int original_text: str + @property + def length(self) -> int: + return self.end - self.start + @property def text(self) -> str: return self.original_text[self.start : self.end] @@ -137,3 +143,6 @@ class Range: def to_json(self): return utils.idxs_to_range(self.start, self.end, self.original_text) + + def overlaps(self, other: "Range") -> bool: + return not (self.end < other.start or self.start > other.end) diff --git a/tests/test_samples.py b/tests/test_samples.py index 9ad8cc0..9d891f6 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -113,9 +113,9 @@ class TestSamples(unittest.TestCase): raise MultipleErrors(warnings) except PrintableError as e: - def error_str(error): - line, col = utils.idx_to_pos(error.start + 1, blueprint) - len = error.end - error.start + def error_str(error: CompileError): + line, col = utils.idx_to_pos(error.range.start + 1, blueprint) + len = error.range.length return ",".join([str(line + 1), str(col), str(len), error.message]) if isinstance(e, CompileError): From 35ee058192cd0a22dbbed15de7aa61bff9ae51e0 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 20:02:03 -0500 Subject: [PATCH 6/7] lsp: Add code action to add missing imports --- blueprintcompiler/gir.py | 27 +++++++++++++++++++++++++-- blueprintcompiler/language/types.py | 11 ++++++++++- blueprintcompiler/language/ui.py | 12 ++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/blueprintcompiler/gir.py b/blueprintcompiler/gir.py index 3642bd6..635ef7c 100644 --- a/blueprintcompiler/gir.py +++ b/blueprintcompiler/gir.py @@ -18,7 +18,6 @@ # SPDX-License-Identifier: LGPL-3.0-or-later import os -import sys import typing as T from functools import cached_property @@ -29,6 +28,7 @@ from gi.repository import GIRepository # type: ignore from . import typelib, xml_reader from .errors import CompileError, CompilerBugError +from .lsp_utils import CodeAction _namespace_cache: T.Dict[str, "Namespace"] = {} _xml_cache = {} @@ -65,6 +65,27 @@ def get_namespace(namespace: str, version: str) -> "Namespace": return _namespace_cache[filename] +_available_namespaces: list[tuple[str, str]] = [] + + +def get_available_namespaces() -> T.List[T.Tuple[str, str]]: + if len(_available_namespaces): + return _available_namespaces + + search_paths: list[str] = [ + *GIRepository.Repository.get_search_path(), + *_user_search_paths, + ] + + for search_path in search_paths: + for filename in os.listdir(search_path): + if filename.endswith(".typelib"): + namespace, version = filename.removesuffix(".typelib").rsplit("-", 1) + _available_namespaces.append((namespace, version)) + + return _available_namespaces + + def get_xml(namespace: str, version: str): search_paths = [] @@ -1011,9 +1032,11 @@ class GirContext: ns = ns or "Gtk" if ns not in self.namespaces and ns not in self.not_found_namespaces: + all_available = list(set(ns for ns, _version in get_available_namespaces())) + raise CompileError( f"Namespace {ns} was not imported", - did_you_mean=(ns, self.namespaces.keys()), + did_you_mean=(ns, all_available), ) def validate_type(self, name: str, ns: str) -> None: diff --git a/blueprintcompiler/language/types.py b/blueprintcompiler/language/types.py index e7b1867..b3fb586 100644 --- a/blueprintcompiler/language/types.py +++ b/blueprintcompiler/language/types.py @@ -55,7 +55,16 @@ class TypeName(AstNode): @validate("namespace") def gir_ns_exists(self): if not self.tokens["extern"]: - self.root.gir.validate_ns(self.tokens["namespace"]) + try: + self.root.gir.validate_ns(self.tokens["namespace"]) + except CompileError as e: + ns = self.tokens["namespace"] + e.actions = [ + self.root.import_code_action(n, version) + for n, version in gir.get_available_namespaces() + if n == ns + ] + raise e @validate() def deprecated(self) -> None: diff --git a/blueprintcompiler/language/ui.py b/blueprintcompiler/language/ui.py index 3ce23da..34ba193 100644 --- a/blueprintcompiler/language/ui.py +++ b/blueprintcompiler/language/ui.py @@ -99,6 +99,18 @@ class UI(AstNode): and self.template.class_name.glib_type_name == id ) + def import_code_action(self, ns: str, version: str) -> CodeAction: + if len(self.children[Import]): + pos = self.children[Import][-1].range.end + else: + pos = self.children[GtkDirective][0].range.end + + return CodeAction( + f"Import {ns} {version}", + f"\nusing {ns} {version};", + Range(pos, pos, self.group.text), + ) + @context(ScopeCtx) def scope_ctx(self) -> ScopeCtx: return ScopeCtx(node=self) From bfa2f56e1f3281bf80f566be44f2e6eda5bf92bd Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 25 Jul 2023 20:07:37 -0500 Subject: [PATCH 7/7] Sort imports --- blueprintcompiler/ast_utils.py | 2 +- blueprintcompiler/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprintcompiler/ast_utils.py b/blueprintcompiler/ast_utils.py index 56501e7..ce15faf 100644 --- a/blueprintcompiler/ast_utils.py +++ b/blueprintcompiler/ast_utils.py @@ -22,7 +22,7 @@ from collections import ChainMap, defaultdict from functools import cached_property from .errors import * -from .lsp_utils import DocumentSymbol, SemanticToken, LocationLink +from .lsp_utils import DocumentSymbol, LocationLink, SemanticToken from .tokenizer import Range TType = T.TypeVar("TType") diff --git a/blueprintcompiler/main.py b/blueprintcompiler/main.py index 416db47..306dd7d 100644 --- a/blueprintcompiler/main.py +++ b/blueprintcompiler/main.py @@ -25,7 +25,7 @@ import sys import typing as T from . import interactive_port, parser, tokenizer -from .errors import CompilerBugError, CompileError, PrintableError, report_bug +from .errors import CompileError, CompilerBugError, PrintableError, report_bug from .gir import add_typelib_search_path from .lsp import LanguageServer from .outputs import XmlOutput