diff --git a/gtk-blueprint-tool.py b/gtk-blueprint-tool.py index 093c988..b5bb8e7 100755 --- a/gtk-blueprint-tool.py +++ b/gtk-blueprint-tool.py @@ -22,4 +22,4 @@ from gtkblueprinttool import main if __name__ == "__main__": - main() + main.main() diff --git a/gtkblueprinttool/ast.py b/gtkblueprinttool/ast.py index 86b8598..6f53e90 100644 --- a/gtkblueprinttool/ast.py +++ b/gtkblueprinttool/ast.py @@ -18,13 +18,102 @@ # SPDX-License-Identifier: LGPL-3.0-or-later -from .errors import assert_true +from .errors import assert_true, AlreadyCaughtError, CompileError, CompilerBugError, MultipleErrors +from .gir import GirContext, get_namespace +from .utils import lazy_prop from .xml_emitter import XmlEmitter +class Validator(): + def __init__(self, func, token_name=None, end_token_name=None): + self.func = func + self.token_name = token_name + self.end_token_name = end_token_name + + def __get__(self, instance, owner): + if instance is None: + return self + + key = "_validation_result_" + self.func.__name__ + + if key + "_err" in instance.__dict__: + # If the validator has failed before, raise a generic Exception. + # We want anything that depends on this validation result to + # fail, but not report the exception twice. + raise AlreadyCaughtError() + + if key not in instance.__dict__: + try: + instance.__dict__[key] = self.func(instance) + except CompileError as e: + # Mark the validator as already failed so we don't print the + # same message again + instance.__dict__[key + "_err"] = True + + # 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 self.token_name is not None and e.start is None: + group = instance.group.tokens.get(self.token_name) + if self.end_token_name is not None and group is None: + group = instance.group.tokens[self.end_token_name] + e.start = group.start + if (self.token_name is not None or self.end_token_name is not None) and e.end is None: + e.end = instance.group.tokens[self.end_token_name or self.token_name].end + + # Re-raise the exception + raise e + + # Return the validation result (which other validators, or the code + # generation phase, might depend on) + return instance.__dict__[key] + + +def validate(*args, **kwargs): + """ Decorator for functions that validate an AST node. Exceptions raised + during validation are marked with range information from the tokens. Also + creates a cached property out of the function. """ + + def decorator(func): + return Validator(func, *args, **kwargs) + + return decorator + + class AstNode: """ Base class for nodes in the abstract syntax tree. """ + def __init__(self): + self.group = None + self.parent = None + self.child_nodes = None + + @lazy_prop + def root(self): + if self.parent is None: + return self + else: + return self.parent.root + + @lazy_prop + def errors(self): + return list(self._get_errors()) + + def _get_errors(self): + for name in dir(type(self)): + item = getattr(type(self), name) + if isinstance(item, Validator): + try: + getattr(self, name) + except AlreadyCaughtError: + pass + except CompileError as e: + yield e + + for child in self.child_nodes: + yield from child._get_errors() + + def generate(self) -> str: """ Generates an XML string from the node. """ xml = XmlEmitter() @@ -40,6 +129,7 @@ class UI(AstNode): """ The AST node for the entire file """ def __init__(self, gtk_directives=[], imports=[], objects=[], templates=[]): + super().__init__() assert_true(len(gtk_directives) == 1) self.gtk_directive = gtk_directives[0] @@ -47,6 +137,22 @@ class UI(AstNode): self.objects = objects self.templates = templates + @validate() + def gir(self): + gir = GirContext() + + gir.add_namespace(self.gtk_directive.gir_namespace) + for i in self.imports: + gir.add_namespace(i.gir_namespace) + + return gir + + @validate() + def at_most_one_template(self): + if len(self.templates) > 1: + raise CompileError(f"Only one template may be defined per file, but this file contains {len(self.templates)}", + self.templates[1].group.start) + def emit_xml(self, xml: XmlEmitter): xml.start_tag("interface") self.gtk_directive.emit_xml(xml) @@ -60,8 +166,21 @@ class UI(AstNode): class GtkDirective(AstNode): child_type = "gtk_directives" def __init__(self, version): + super().__init__() self.version = version + @validate("version") + def gir_namespace(self): + if self.version in ["4.0"]: + return get_namespace("Gtk", self.version) + else: + err = CompileError("Only GTK 4 is supported") + if self.version.startswith("4"): + err.hint("Expected the GIR version, not an exact version number. Use `@gtk \"4.0\";`.") + else: + err.hint("Expected `@gtk \"4.0\";`") + raise err + def emit_xml(self, xml: XmlEmitter): xml.put_self_closing("requires", lib="gtk", version=self.version) @@ -69,9 +188,14 @@ class GtkDirective(AstNode): class Import(AstNode): child_type = "imports" def __init__(self, namespace, version): + super().__init__() self.namespace = namespace self.version = version + @validate("namespace", "version") + def gir_namespace(self): + return get_namespace(self.namespace, self.version) + def emit_xml(self, xml: XmlEmitter): pass @@ -79,6 +203,7 @@ class Import(AstNode): class Template(AstNode): child_type = "templates" def __init__(self, name, class_name, object_content, namespace=None): + super().__init__() assert_true(len(object_content) == 1) self.name = name @@ -86,10 +211,16 @@ class Template(AstNode): self.parent_class = class_name self.object_content = object_content[0] + + @validate("namespace", "class_name") + def gir_parent(self): + return self.root.gir.get_class(self.parent_class, self.parent_namespace) + + def emit_xml(self, xml: XmlEmitter): xml.start_tag("template", **{ "class": self.name, - "parent": self.parent_namespace + self.parent_class, + "parent": self.gir_parent.glib_type_name, }) self.object_content.emit_xml(xml) xml.end_tag() @@ -98,6 +229,7 @@ class Template(AstNode): class Object(AstNode): child_type = "objects" def __init__(self, class_name, object_content, namespace=None, id=None): + super().__init__() assert_true(len(object_content) == 1) self.namespace = namespace @@ -105,9 +237,13 @@ class Object(AstNode): self.id = id self.object_content = object_content[0] + @validate("namespace", "class_name") + def gir_class(self): + return self.root.gir.get_class(self.class_name, self.namespace) + def emit_xml(self, xml: XmlEmitter): xml.start_tag("object", **{ - "class": self.namespace + self.class_name, + "class": self.gir_class.glib_type_name, "id": self.id, }) self.object_content.emit_xml(xml) @@ -117,6 +253,7 @@ class Object(AstNode): class Child(AstNode): child_type = "children" def __init__(self, objects, child_type=None): + super().__init__() assert_true(len(objects) == 1) self.object = objects[0] self.child_type = child_type @@ -130,28 +267,62 @@ class Child(AstNode): class ObjectContent(AstNode): child_type = "object_content" def __init__(self, properties=[], signals=[], children=[]): + super().__init__() self.properties = properties self.signals = signals self.children = children def emit_xml(self, xml: XmlEmitter): - for prop in self.properties: - prop.emit_xml(xml) - for signal in self.signals: - signal.emit_xml(xml) - for child in self.children: - child.emit_xml(xml) + for x in [*self.properties, *self.signals, *self.children]: + x.emit_xml(xml) class Property(AstNode): child_type = "properties" def __init__(self, name, value=None, translatable=False, bind_source=None, bind_property=None): + super().__init__() self.name = name self.value = value self.translatable = translatable self.bind_source = bind_source self.bind_property = bind_property + + @validate() + def gir_property(self): + if self.gir_class is not None: + return self.gir_class.properties.get(self.name) + + @validate() + def gir_class(self): + parent = self.parent.parent + if isinstance(parent, Template): + return parent.gir_parent + elif isinstance(parent, Object): + return parent.gir_class + else: + raise CompilerBugError() + + + @validate("name") + def property_exists(self): + if self.gir_class is None: + # Objects that we have no gir data on should not be validated + # This happens for classes defined by the app itself + return + + if isinstance(self.parent.parent, Template): + # If the property is part of a template, it might be defined by + # the application and thus not in gir + return + + if self.gir_property is None: + raise CompileError( + f"Class {self.gir_class.full_name} does not contain a property called {self.name}", + did_you_mean=(self.name, self.gir_class.properties.keys()) + ) + + def emit_xml(self, xml: XmlEmitter): props = { "name": self.name, @@ -170,6 +341,7 @@ class Property(AstNode): class Signal(AstNode): child_type = "signals" def __init__(self, name, handler, swapped=False, after=False, object=False, detail_name=None): + super().__init__() self.name = name self.handler = handler self.swapped = swapped @@ -177,6 +349,42 @@ class Signal(AstNode): self.object = object self.detail_name = detail_name + + @validate() + def gir_signal(self): + if self.gir_class is not None: + return self.gir_class.signals.get(self.name) + + @validate() + def gir_class(self): + parent = self.parent.parent + if isinstance(parent, Template): + return parent.gir_parent + elif isinstance(parent, Object): + return parent.gir_class + else: + raise CompilerBugError() + + + @validate("name") + def signal_exists(self): + if self.gir_class is None: + # Objects that we have no gir data on should not be validated + # This happens for classes defined by the app itself + return + + if isinstance(self.parent.parent, Template): + # If the signal is part of a template, it might be defined by + # the application and thus not in gir + return + + if self.gir_signal is None: + print(self.gir_class.signals.keys()) + raise CompileError( + f"Class {self.gir_class.full_name} does not contain a signal called {self.name}", + did_you_mean=(self.name, self.gir_class.signals.keys()) + ) + def emit_xml(self, xml: XmlEmitter): name = self.name if self.detail_name: diff --git a/gtkblueprinttool/errors.py b/gtkblueprinttool/errors.py index 524ef9f..fc16dfa 100644 --- a/gtkblueprinttool/errors.py +++ b/gtkblueprinttool/errors.py @@ -19,6 +19,7 @@ import sys, traceback +from . import utils class _colors: @@ -37,34 +38,63 @@ class PrintableError(Exception): class CompileError(PrintableError): + """ A PrintableError with a start/end position and optional hints """ + category = "error" - def __init__(self, message, start, end=None): + def __init__(self, message, start=None, end=None, did_you_mean=None, hints=None): super().__init__(message) self.message = message self.start = start self.end = end + self.hints = hints or [] - def pretty_print(self, filename, code): - sp = code[:self.start+1].splitlines(keepends=True) + if did_you_mean is not None: + self._did_you_mean(*did_you_mean) + + def hint(self, hint: str): + self.hints.append(hint) + return self + + + def _did_you_mean(self, word: str, options: [str]): + if word.replace("_", "-") in options: + self.hint(f"use '-', not '_': `{word.replace('_', '-')}`") + return + + recommend = utils.did_you_mean(word, options) + if recommend is not None: + if word.casefold() == recommend.casefold(): + self.hint(f"Did you mean `{recommend}` (note the capitalization)?") + else: + self.hint(f"Did you mean `{recommend}`?") + else: + self.hint("Did you check your spelling?") + self.hint("Are your dependencies up to date?") + + def line_col_from_index(self, code, index): + sp = code[:index].splitlines(keepends=True) line_num = len(sp) col_num = len(sp[-1]) + return (line_num, col_num) + + def pretty_print(self, filename, code): + line_num, col_num = self.line_col_from_index(code, self.start + 1) line = code.splitlines(True)[line_num-1] print(f"""{_colors.RED}{_colors.BOLD}{self.category}: {self.message}{_colors.CLEAR} at {filename} line {line_num} column {col_num}: -{_colors.FAINT}{line_num :>4} |{_colors.CLEAR}{line.rstrip()}\n {_colors.FAINT}|{" "*(col_num-1)}^{_colors.CLEAR} -""") +{_colors.FAINT}{line_num :>4} |{_colors.CLEAR}{line.rstrip()}\n {_colors.FAINT}|{" "*(col_num-1)}^{_colors.CLEAR}""") + + for hint in self.hints: + print(f"{_colors.FAINT}hint: {hint}{_colors.CLEAR}") + print() -class TokenizeError(CompileError): - def __init__(self, start): - super().__init__("Could not determine what kind of syntax is meant here", start) - - -class ParseError(CompileError): - pass +class AlreadyCaughtError(Exception): + """ Emitted when a validation has already failed and its error message + should not be repeated. """ class MultipleErrors(PrintableError): diff --git a/gtkblueprinttool/gir.py b/gtkblueprinttool/gir.py new file mode 100644 index 0000000..b215387 --- /dev/null +++ b/gtkblueprinttool/gir.py @@ -0,0 +1,249 @@ +# gir.py +# +# Copyright 2021 James Westman +# +# This file is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see . +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +import os, sys + +from .errors import CompileError, CompilerBugError +from .utils import lazy_prop +from . import xml_reader + + +extra_search_paths = [] +_namespace_cache = {} + +_search_paths = [] +xdg_data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) +_search_paths.append(os.path.join(xdg_data_home, "gir-1.0")) +xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/share:/usr/local/share").split(":") +_search_paths += [os.path.join(dir, "gir-1.0") for dir in xdg_data_dirs] + + +def get_namespace(namespace, version): + filename = f"{namespace}-{version}.gir" + + if filename not in _namespace_cache: + for search_path in _search_paths: + path = os.path.join(search_path, filename) + + if os.path.exists(path) and os.path.isfile(path): + xml = xml_reader.parse(path, xml_reader.PARSE_GIR) + repository = Repository(xml) + + _namespace_cache[filename] = repository.namespaces.get(namespace) + break + + if filename not in _namespace_cache: + raise CompileError(f"Namespace `{namespace}-{version}` could not be found.") + + return _namespace_cache[filename] + + +class GirNode: + def __init__(self, xml): + self.xml = xml + + @lazy_prop + def glib_type_name(self): + return self.xml["glib:type-name"] + + @lazy_prop + def name(self) -> str: + return self.xml["name"] + + @lazy_prop + def available_in(self) -> str: + return self.xml.get("version") + + @lazy_prop + def doc(self) -> str: + el = self.xml.find("doc") + if el is None: + return None + return el.cdata + + +class Property(GirNode): + pass + + +class Signal(GirNode): + pass + + +class Interface(GirNode): + def __init__(self, ns, xml: xml_reader.Element): + super().__init__(xml) + self.ns = ns + self.properties = {child["name"]: Property(child) for child in xml.get_elements("property")} + self.signals = {child["name"]: Signal(child) for child in xml.get_elements("glib:signal")} + + +class Class(GirNode): + def __init__(self, ns, xml: xml_reader.Element): + super().__init__(xml) + self.ns = ns + self._parent = xml["parent"] + self.implements = [impl["name"] for impl in xml.get_elements("implements")] + self.own_properties = {child["name"]: Property(child) for child in xml.get_elements("property")} + self.own_signals = {child["name"]: Signal(child) for child in xml.get_elements("glib:signal")} + + @lazy_prop + def properties(self): + return { p.name: p for p in self._enum_properties() } + + @lazy_prop + def signals(self): + return { s.name: s for s in self._enum_signals() } + + @lazy_prop + def full_name(self): + return f"{self.ns.name}.{self.name}" + + @lazy_prop + def parent(self): + if self._parent is None: + return None + return self.ns.lookup_class(self._parent) + + + def _enum_properties(self): + yield from self.own_properties.values() + + if self.parent is not None: + yield from self.parent.properties.values() + + for impl in self.implements: + yield from self.ns.lookup_interface(impl).properties.values() + + def _enum_signals(self): + yield from self.own_signals.values() + + if self.parent is not None: + yield from self.parent.signals.values() + + for impl in self.implements: + yield from self.ns.lookup_interface(impl).signals.values() + + +class Namespace(GirNode): + def __init__(self, repo, xml: xml_reader.Element): + super().__init__(xml) + self.repo = repo + self.classes = { child["name"]: Class(self, child) for child in xml.get_elements("class") } + self.interfaces = { child["name"]: Interface(self, child) for child in xml.get_elements("interface") } + self.version = xml["version"] + + def lookup_class(self, name: str): + if "." in name: + ns, cls = name.split(".") + return self.repo.lookup_namespace(ns).lookup_class(cls) + else: + return self.classes.get(name) + + def lookup_interface(self, name: str): + if "." in name: + ns, iface = name.split(".") + return self.repo.lookup_namespace(ns).lookup_interface(iface) + else: + return self.interfaces.get(name) + + def lookup_namespace(self, ns: str): + return self.repo.lookup_namespace(ns) + + +class Repository(GirNode): + def __init__(self, xml: xml_reader.Element): + super().__init__(xml) + self.namespaces = { child["name"]: Namespace(self, child) for child in xml.get_elements("namespace") } + + try: + self.includes = { include["name"]: get_namespace(include["name"], include["version"]) for include in xml.get_elements("include") } + except: + raise CompilerBugError(f"Failed to load dependencies of {namespace}-{version}") + + def lookup_namespace(self, name: str): + ns = self.namespaces.get(name) + if ns is not None: + return ns + for include in self.includes.values(): + ns = include.lookup_namespace(name) + if ns is not None: + return ns + + +class GirContext: + def __init__(self): + self.namespaces = {} + self.incomplete = set([]) + + + def add_namespace(self, namespace: Namespace): + other = self.namespaces.get(namespace.name) + if other is not None and other.version != namespace.version: + raise CompileError(f"Namespace {namespace}-{version} can't be imported because version {other.version} was imported earlier") + + self.namespaces[namespace.name] = namespace + + + def add_incomplete(self, namespace: str): + """ Adds an "incomplete" namespace for which missing items won't cause + errors. """ + self.incomplete.add(namespace) + + + def get_class(self, name: str, ns:str=None) -> Class: + if ns is None: + options = [namespace.classes[name] + for namespace in self.namespaces.values() + if name in namespace.classes] + + if len(options) == 1: + return options[0] + elif len(options) == 0: + raise CompileError( + f"No imported namespace contains a class called {name}", + hints=[ + "Did you forget to import a namespace?", + "Did you check your spelling?", + "Are your dependencies up to date?", + ], + ) + else: + raise CompileError( + f"Class name {name} is ambiguous", + hints=[ + f"Specify the namespace, e.g. `{options[0].ns.name}.{name}`", + f"Namespaces with a class named {name}: {', '.join([cls.ns.name for cls in options])}", + ], + ) + + else: + if ns not in self.namespaces: + raise CompileError( + f"Namespace `{ns}` was not imported.", + did_you_mean=(ns, self.namespaces.keys()), + ) + + if name not in self.namespaces[ns].classes: + raise CompileError( + f"Namespace {ns} does not contain a class called {name}.", + did_you_mean=(name, self.namespaces[ns].classes.keys()), + ) + + return self.namespaces[ns].classes[name] diff --git a/gtkblueprinttool/main.py b/gtkblueprinttool/main.py index 59227b7..65ce380 100644 --- a/gtkblueprinttool/main.py +++ b/gtkblueprinttool/main.py @@ -18,9 +18,10 @@ # SPDX-License-Identifier: LGPL-3.0-or-later -import argparse, sys +import argparse, json, os, sys -from .errors import PrintableError, report_compile_error +from .errors import PrintableError, report_compile_error, MultipleErrors +from .lsp import LanguageServer from .pipeline import Pipeline from . import parser, tokenizer @@ -43,6 +44,8 @@ class BlueprintApp: try: opts = self.parser.parse_args() opts.func(opts) + except SystemExit as e: + raise e except: report_compile_error() @@ -60,7 +63,12 @@ class BlueprintApp: try: tokens = tokenizer.tokenize(data) ast = parser.parse(tokens) + + if len(ast.errors): + raise MultipleErrors(ast.errors) + xml = ast.generate() + if opts.output == "-": print(xml) else: diff --git a/gtkblueprinttool/parse_tree.py b/gtkblueprinttool/parse_tree.py index 74a4858..55f00f2 100644 --- a/gtkblueprinttool/parse_tree.py +++ b/gtkblueprinttool/parse_tree.py @@ -22,7 +22,7 @@ from enum import Enum from .ast import AstNode -from .errors import assert_true, CompilerBugError, CompileError, ParseError +from .errors import assert_true, CompilerBugError, CompileError from .tokenizer import Token, TokenType @@ -57,10 +57,13 @@ 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): + def __init__(self, ast_type, start: int): self.ast_type = ast_type self.children = {} self.keys = {} + self.tokens = {} + self.start = start + self.end = None def add_child(self, child): child_type = child.ast_type.child_type @@ -68,10 +71,11 @@ class ParseGroup: self.children[child_type] = [] self.children[child_type].append(child) - def set_val(self, key, val): + def set_val(self, key, val, token): assert_true(key not in self.keys) self.keys[key] = val + self.tokens[key] = token def to_ast(self) -> AstNode: """ Creates an AST node from the match group. """ @@ -80,7 +84,12 @@ class ParseGroup: for child_type, children in self.children.items() } try: - return self.ast_type(**children, **self.keys) + ast = self.ast_type(**children, **self.keys) + ast.group = self + ast.child_nodes = [c for child_type in children.values() for c in child_type] + for child in ast.child_nodes: + child.parent = ast + return ast except TypeError as e: raise CompilerBugError(f"Failed to construct ast.{self.ast_type.__name__} from ParseGroup. See the previous stacktrace.") @@ -113,7 +122,10 @@ 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. """ - return ParseContext(self.tokens, self.index) + ctx = ParseContext(self.tokens, self.index) + ctx.errors = self.errors + ctx.warnings = self.warnings + return ctx def apply_child(self, other): """ Applies a child context to this context. """ @@ -121,10 +133,11 @@ class ParseContext: if other.group is not None: # If the other context had a match group, collect all the matched # values into it and then add it to our own match group. - for key, val in other.group_keys.items(): - other.group.set_val(key, val) + for key, (val, token) in other.group_keys.items(): + other.group.set_val(key, val, token) for child in other.group_children: other.group.add_child(child) + other.group.end = other.tokens[other.index - 1].end self.group_children.append(other.group) else: # If the other context had no match group of its own, collect all @@ -144,23 +157,12 @@ class ParseContext: def start_group(self, ast_type): """ Sets this context to have its own match group. """ assert_true(self.group is None) - self.group = ParseGroup(ast_type) + self.group = ParseGroup(ast_type, self.tokens[self.index].start) - def set_group_val(self, key, value): + def set_group_val(self, key, value, 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 - - - def create_parse_error(self, message): - """ Creates a ParseError identifying the current token index. """ - start_idx = self.start - while self.tokens[start_idx].type in _SKIP_TOKENS: - start_idx += 1 - - start_token = self.tokens[start_idx] - end_token = self.tokens[self.index] - return ParseError(message, start_token.start, end_token.end) + self.group_keys[key] = (value, token) def skip(self): @@ -224,7 +226,32 @@ class Err(ParseNode): def _parse(self, ctx): if self.child.parse(ctx).failed(): - raise ctx.create_parse_error(self.message) + 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) + return True + + +class Fail(ParseNode): + """ ParseNode that emits a compile error if it parses successfully. """ + + def __init__(self, child, message): + self.child = child + self.message = message + + def _parse(self, ctx): + if self.child.parse(ctx).succeeded(): + 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) return True @@ -250,6 +277,7 @@ class Group(ParseNode): self.child = child def _parse(self, ctx: ParseContext) -> bool: + ctx.skip() ctx.start_group(self.ast_type) return self.child.parse(ctx).succeeded() @@ -367,16 +395,15 @@ class UseIdent(ParseNode): if token.type != TokenType.IDENT: return False - ctx.set_group_val(self.key, str(token)) + ctx.set_group_val(self.key, str(token), token) return True class UseNumber(ParseNode): """ ParseNode that matches a number and sets it in a key=value pair on the containing match group. """ - def __init__(self, key, keep_trailing_decimal=False): + def __init__(self, key): self.key = key - self.keep_trailing_decimal = keep_trailing_decimal def _parse(self, ctx: ParseContext): token = ctx.next_token() @@ -384,9 +411,9 @@ class UseNumber(ParseNode): return False number = token.get_number() - if not self.keep_trailing_decimal and number % 1.0 == 0: + if number % 1.0 == 0: number = int(number) - ctx.set_group_val(self.key, number) + ctx.set_group_val(self.key, number, token) return True @@ -405,7 +432,7 @@ class UseQuoted(ParseNode): .replace("\\n", "\n") .replace("\\\"", "\"") .replace("\\\\", "\\")) - ctx.set_group_val(self.key, string) + ctx.set_group_val(self.key, string, token) return True @@ -418,7 +445,7 @@ class UseLiteral(ParseNode): self.literal = literal def _parse(self, ctx: ParseContext): - ctx.set_group_val(self.key, self.literal) + ctx.set_group_val(self.key, self.literal, None) return True diff --git a/gtkblueprinttool/parser.py b/gtkblueprinttool/parser.py index 38b0e63..7488f65 100644 --- a/gtkblueprinttool/parser.py +++ b/gtkblueprinttool/parser.py @@ -19,7 +19,7 @@ from . import ast -from .errors import MultipleErrors, ParseError +from .errors import MultipleErrors from .parse_tree import * from .tokenizer import TokenType @@ -31,7 +31,8 @@ def parse(tokens) -> ast.UI: ast.GtkDirective, Sequence( Directive("gtk"), - UseNumber("version", True).expected("a version number for GTK"), + Fail(UseNumber(None), "Version number must be in quotation marks"), + UseQuoted("version").expected("a version number for GTK"), StmtEnd().expected("`;`"), ) ) @@ -41,7 +42,8 @@ def parse(tokens) -> ast.UI: Sequence( Directive("import"), UseIdent("namespace").expected("a GIR namespace"), - UseNumber("version", True).expected("a version number"), + Fail(UseNumber(None), "Version number must be in quotation marks"), + UseQuoted("version").expected("a version number"), StmtEnd().expected("`;`"), ) ).recover() diff --git a/gtkblueprinttool/tokenizer.py b/gtkblueprinttool/tokenizer.py index 91bc2aa..2f06381 100644 --- a/gtkblueprinttool/tokenizer.py +++ b/gtkblueprinttool/tokenizer.py @@ -21,7 +21,7 @@ import re from enum import Enum -from .errors import TokenizeError +from .errors import CompileError class TokenType(Enum): @@ -106,7 +106,7 @@ def _tokenize(ui_ml: str): break if not matched: - raise TokenizeError(i) + raise CompileError("Could not determine what kind of syntax is meant here", i) yield Token(TokenType.EOF, i, i, ui_ml) diff --git a/gtkblueprinttool/utils.py b/gtkblueprinttool/utils.py new file mode 100644 index 0000000..06a6ace --- /dev/null +++ b/gtkblueprinttool/utils.py @@ -0,0 +1,67 @@ +# utils.py +# +# Copyright 2021 James Westman +# +# This file is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see . +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +import typing as T + + +def lazy_prop(func): + key = "_lazy_prop_" + func.__name__ + + @property + def real_func(self): + if key not in self.__dict__: + self.__dict__[key] = func(self) + return self.__dict__[key] + + return real_func + + +def did_you_mean(word: str, options: [str]) -> T.Optional[str]: + if len(options) == 0: + return None + + def levenshtein(a, b): + # see https://en.wikipedia.org/wiki/Levenshtein_distance + m = len(a) + n = len(b) + + distances = [[0 for j in range(n)] for i in range(m)] + + for i in range(m): + distances[i][0] = i + for j in range(n): + distances[0][j] = j + + for j in range(1, n): + for i in range(1, m): + cost = 0 + if a[i] != b[j]: + if a[i].casefold() == b[j].casefold(): + cost = 1 + else: + cost = 2 + distances[i][j] = min(distances[i-1][j] + 2, distances[i][j-1] + 2, distances[i-1][j-1] + cost) + + return distances[m-1][n-1] + + distances = [(option, levenshtein(word, option)) for option in options] + closest = min(distances, key=lambda item:item[1]) + if closest[1] <= 5: + return closest[0] + return None diff --git a/gtkblueprinttool/xml_reader.py b/gtkblueprinttool/xml_reader.py new file mode 100644 index 0000000..621c035 --- /dev/null +++ b/gtkblueprinttool/xml_reader.py @@ -0,0 +1,85 @@ +# xml_reader.py +# +# Copyright 2021 James Westman +# +# This file is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see . +# +# SPDX-License-Identifier: LGPL-3.0-or-later + + +from collections import defaultdict +from xml import sax + +from .utils import lazy_prop + + +PARSE_GIR = set([ + "repository", "namespace", "class", "interface", "property", "glib:signal", + "include", +]) + + +class Element: + def __init__(self, tag, attrs): + self.tag = tag + self.attrs = attrs + self.children = defaultdict(list) + self.cdata_chunks = [] + + @lazy_prop + def cdata(self): + return ''.join(self.cdata_chunks) + + def get_elements(self, name): + return self.children.get(name, []) + + def __getitem__(self, key): + return self.attrs.get(key) + + +class Handler(sax.handler.ContentHandler): + def __init__(self, parse_type): + self.root = None + self.stack = [] + self._interesting_elements = parse_type + + def startElement(self, name, attrs): + if name not in self._interesting_elements: + return + + element = Element(name, attrs.copy()) + + if len(self.stack): + last = self.stack[-1] + last.children[name].append(element) + else: + self.root = element + + self.stack.append(element) + + + def endElement(self, name): + if name in self._interesting_elements: + self.stack.pop() + + def characters(self, content): + self.stack[-1].cdata_chunks.append(content) + + +def parse(filename, parse_type): + parser = sax.make_parser() + handler = Handler(parse_type) + parser.setContentHandler(handler) + parser.parse(filename) + return handler.root