diff --git a/djlsp/index.py b/djlsp/index.py index 41ee235..bd9e54c 100644 --- a/djlsp/index.py +++ b/djlsp/index.py @@ -57,6 +57,7 @@ class WorkspaceIndex: libraries: dict[str, Library] = field(default_factory=dict) templates: dict[str, Template] = field(default_factory=dict) global_template_context: dict[str, Variable] = field(default_factory=dict) + css_class_names: list = field(default_factory=list) def update(self, django_data: dict): self.file_watcher_globs = django_data.get( @@ -121,3 +122,4 @@ def update(self, django_data: dict): ) for name, type_ in django_data.get("global_template_context", {}).items() } + self.css_class_names = django_data.get("css_class_names", []) diff --git a/djlsp/parser.py b/djlsp/parser.py index 46bfc9a..8a2a77d 100644 --- a/djlsp/parser.py +++ b/djlsp/parser.py @@ -15,6 +15,7 @@ Location, Position, Range, + TextEdit, ) from pygls.workspace import TextDocument @@ -330,7 +331,8 @@ def completions(self, line, character): ), ) ) - return [] + # Check CSS + return self.get_css_class_name_completions(line, character) def get_load_completions(self, match: Match, **kwargs): prefix = match.group(1).split(" ")[-1] @@ -558,6 +560,58 @@ def resolve_completion(item: CompletionItem): return item + def _absolute_index_to_position(self, index: int) -> Position: + """Translate an absolute character index into an LSP `Position`.""" + running_total = 0 + for line_no, text in enumerate(self.document.lines): + line_length = len(text) + if index <= running_total + line_length: + return Position(line=line_no, character=index - running_total) + running_total += line_length + # Fallback to end of document if index is out of bounds + last_line = len(self.document.lines) - 1 + last_char = len(self.document.lines[last_line]) if last_line >= 0 else 0 + return Position(line=last_line, character=last_char) + + def get_css_class_name_completions(self, line, character): + # Flatten text to one line and remove Django template + one_line_html = "".join(self.document.lines) + one_line_html = re.sub( + r"\{\%.*?\%\}", lambda m: " " * len(m.group(0)), one_line_html + ) + one_line_html = re.sub( + r"\{\{.*?\}\}", lambda m: " " * len(m.group(0)), one_line_html + ) + + abs_position = sum(len(self.document.lines[i]) for i in range(line)) + character + class_attr_pattern = re.compile(r'class=["\']([^"\']*)["\']', re.DOTALL) + + for match in class_attr_pattern.finditer(one_line_html): + start_idx, end_idx = match.span(1) + + if start_idx <= abs_position <= end_idx: + class_value = match.group(1) + relative_pos = abs_position - start_idx + + prefix_match = re.search(r"\b[\w-]*$", class_value[:relative_pos]) + prefix = prefix_match.group(0) if prefix_match else "" + + start_index = abs_position - len(prefix) + start_position = self._absolute_index_to_position(start_index) + end_position = self._absolute_index_to_position(abs_position) + replace_range = Range(start=start_position, end=end_position) + + return [ + CompletionItem( + label=class_name, + text_edit=TextEdit(range=replace_range, new_text=class_name), + ) + for class_name in self.workspace_index.css_class_names + if class_name.startswith(prefix) + ] + + return [] + ################################################################################### # Hover ################################################################################### diff --git a/djlsp/scripts/django-collector.py b/djlsp/scripts/django-collector.py index 984fa43..fb6c59f 100644 --- a/djlsp/scripts/django-collector.py +++ b/djlsp/scripts/django-collector.py @@ -196,6 +196,7 @@ def __init__(self, project_src_path): self.libraries = {} self.templates: dict[str, Template] = {} self.global_template_context = {} + self.css_class_names = [] def collect(self): self.file_watcher_globs = self.get_file_watcher_globs() @@ -204,6 +205,7 @@ def collect(self): self.urls = self.get_urls() self.libraries = self.get_libraries() self.global_template_context = self.get_global_template_context() + self.css_class_names = self.get_css_class_names() # Third party collectors self.collect_for_wagtail() @@ -217,6 +219,7 @@ def to_json(self): "libraries": self.libraries, "templates": self.templates, "global_template_context": self.global_template_context, + "css_class_names": self.css_class_names, }, indent=4, ) @@ -573,6 +576,23 @@ def collect_for_wagtail(self): model.context_object_name ] = (model.__module__ + "." + model.__name__) + # CSS class names + # --------------------------------------------------------------------------------- + def get_css_class_names(self): + class_pattern = re.compile(r"\.(?!\d)([a-zA-Z0-9_-]+)") + class_names = set() + + for finder in get_finders(): + for path, file_storage in finder.list(None): + if path.endswith(".css") and not path.startswith("admin"): + try: + with file_storage.open(path, "r") as f: + content = f.read() + class_names.update(class_pattern.findall(content)) + except Exception as e: + logger.error(f"Could not parse CSS file: {e}") + return list(class_names) + ####################################################################################### # CLI diff --git a/djlsp/server.py b/djlsp/server.py index 6354e70..5419bf1 100644 --- a/djlsp/server.py +++ b/djlsp/server.py @@ -433,7 +433,7 @@ def initialized(ls: DjangoTemplateLanguageServer, params: InitializeParams): @server.feature( TEXT_DOCUMENT_COMPLETION, CompletionOptions( - trigger_characters=[" ", "|", "'", '"', "."], resolve_provider=True + trigger_characters=[" ", "|", "'", '"', ".", "-"], resolve_provider=True ), ) def completions(ls: DjangoTemplateLanguageServer, params: CompletionParams): diff --git a/tests/django_test/test-static-folder/website.css b/tests/django_test/test-static-folder/website.css new file mode 100644 index 0000000..03e1ef0 --- /dev/null +++ b/tests/django_test/test-static-folder/website.css @@ -0,0 +1,11 @@ +.blog .item { + color: red; +} + +.border-red-solid { + border: 1px solid red; +} + +.border_blue-solid { + border: 1px solid blue; +}