diff --git a/.gitignore b/.gitignore index 3adadd74..6257c801 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ EasyClangComplete.sublime-workspace tests/bazel/good_project/bazel-* *compile_commands.json tests/bazel/bad_project/bazel-* + +package-metadata.json + diff --git a/EasyClangComplete.sublime-settings b/EasyClangComplete.sublime-settings index 1bd0dba1..2e7282de 100644 --- a/EasyClangComplete.sublime-settings +++ b/EasyClangComplete.sublime-settings @@ -87,6 +87,16 @@ "popup_maximum_width": 1800, "popup_maximum_height": 800, + // This array determines which sections should appear inside info popups, + // and in what order they should be displayed within the popup. + // By default, all of the possible sections are present. + "popup_sections": [ + "Declaration", + "References", + "Documentation", + "Body", + ], + // Triggers for auto-completion "triggers" : [ ".", "->", "::", " ", " ", "(", "[" ], @@ -170,6 +180,11 @@ // the symbol under cursor taking them from Sublime Text index. "show_index_references": true, + // Makes any documentation comments show up as rendered Markdown in the popup. + // By default, this value is set to `false`, which means that any documentation + // comments will be displayed within a literal text block (```). + "show_doc_as_markdown": false, + // When an includes trigger is typed (" or <) a quick panel will appear that // will guide the user in picking their includes based on the current // compilation database' include flags. diff --git a/docs/settings.md b/docs/settings.md index 8db64696..16239935 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -261,6 +261,22 @@ Setting that controls the maximum height of the popups generated by the plugin. "popup_maximum_height": 800, ``` +### **`popup_sections`** + +This array determines which sections should appear inside info popups, +and in what order they should be displayed within the popup. +By default, all of the possible sections are present. + +!!! example "Default value" + ```json + "popup_sections": [ + "Declaration", + "References", + "Documentation", + "Body", + ], + ``` + ### **`triggers`** Defines all characters that trigger auto-completion. The default value is: @@ -459,6 +475,17 @@ symbol under cursor taking them from Sublime Text index. "show_index_references": true, ``` +### **`show_doc_as_markdown`** + +Makes any documentation comments show up as rendered Markdown in the popup. +By default, this value is set to `false`, which means that any documentation +comments will be displayed within a literal text block (triple-backquotes). + +!!! example "Default value" + ```json + "show_doc_as_markdown": false, + ``` + ### **`autocomplete_includes`** diff --git a/plugin/completion/lib_complete.py b/plugin/completion/lib_complete.py index 2dd93dd1..9dda976d 100644 --- a/plugin/completion/lib_complete.py +++ b/plugin/completion/lib_complete.py @@ -287,6 +287,9 @@ def info(self, tooltip_request, settings): info_popup = Popup.info( cursor.referenced, self.cindex, settings) return tooltip_request, info_popup + if cursor.kind == self.cindex.CursorKind.MACRO_DEFINITION: + info_popup = Popup.info(cursor, self.cindex, settings) + return tooltip_request, info_popup return empty_info def update(self, view, settings): diff --git a/plugin/error_vis/popups.py b/plugin/error_vis/popups.py index 78fad254..b73b11e0 100644 --- a/plugin/error_vis/popups.py +++ b/plugin/error_vis/popups.py @@ -1,5 +1,6 @@ """Incapsulate popup creation.""" +import string import sublime import mdpopups import markupsafe @@ -45,7 +46,7 @@ {content} """ -FULL_DOC_TEMPLATE = """### Full doxygen comment: +FULL_DOC_TEMPLATE = """### Detailed documentation: {content} """ @@ -94,31 +95,54 @@ def info(cursor, cindex, settings): settings.popup_maximum_width, settings.popup_maximum_height )) popup.__popup_type = 'panel-info "ECC: Info"' - is_type_decl = cursor.kind in [ - cindex.CursorKind.STRUCT_DECL, - cindex.CursorKind.UNION_DECL, - cindex.CursorKind.CLASS_DECL, - cindex.CursorKind.ENUM_DECL, - cindex.CursorKind.TYPEDEF_DECL, - cindex.CursorKind.TYPE_ALIAS_DECL, - cindex.CursorKind.TYPE_REF - ] - is_macro = cursor.kind == cindex.CursorKind.MACRO_DEFINITION - is_class_template = cursor.kind == cindex.CursorKind.CLASS_TEMPLATE - # Show the return type of the function/method if applicable, - # macros just show that they are a macro. macro_parser = None - body_cursor = None - if is_type_decl: - body_cursor = cursor - elif is_class_template: - body_cursor = cursor.get_definition() + if cursor.kind == cindex.CursorKind.MACRO_DEFINITION: + macro_parser = MacroParser(cursor.spelling, cursor.location) + + if not isinstance(settings.popup_sections, list): + log.error("Bad config value: \"popup_sections\" " + + "should be a list of strings") + elif len(settings.popup_sections) == 0: + log.error("Bad config value: \"popup_sections\" " + + "setting should have at least one element") + else: + popup.__text = "" + for i in settings.popup_sections: + if not isinstance(i, str): + log.error("Bad config value: \"popup_sections\" " + + "should be a list containing only strings") + elif re.match(r'[Dd]eclaration', i): + popup.__text += Popup.info_section_declaration( + cursor, cindex, settings, macro_parser) + elif re.match(r'[Rr]eferences', i): + popup.__text += Popup.info_section_references( + cursor, cindex, settings, macro_parser) + elif re.match(r'[Dd]ocumentation', i): + popup.__text += Popup.info_section_documentation( + cursor, cindex, settings, macro_parser) + elif re.match(r'([Bb]ody|[Ss]ource)', i): + popup.__text += Popup.info_section_body( + cursor, cindex, settings, macro_parser) + else: + log.error("Bad config value: \"popup_sections\" " + + "has unknown value: \"" + i + "\"") + + return popup - # Initialize the text the declaration. + @staticmethod + def info_section_declaration(cursor, cindex, settings, macro_parser): + """Generate the info text for the declaration.""" + is_function = cursor.kind in [ + cindex.CursorKind.FUNCTION_DECL, + cindex.CursorKind.CXX_METHOD, + cindex.CursorKind.CONSTRUCTOR, + cindex.CursorKind.DESTRUCTOR, + cindex.CursorKind.CONVERSION_FUNCTION, + cindex.CursorKind.FUNCTION_TEMPLATE + ] declaration_text = '' - if is_macro: - macro_parser = MacroParser(cursor.spelling, cursor.location) + if macro_parser is not None: declaration_text += r'\#define ' else: if cursor.result_type.spelling: @@ -143,7 +167,7 @@ def info(cursor, cindex, settings): declaration_text += cursor.spelling # Macro/function/method arguments args_string = None - if is_macro: + if macro_parser is not None: # cursor.get_arguments() doesn't give us anything for macros, # so we have to parse those ourselves args_string = macro_parser.args_string @@ -156,12 +180,12 @@ def info(cursor, cindex, settings): args.append(arg_type_decl + " " + arg.spelling) else: args.append(arg_type_decl) - if cursor.kind in [cindex.CursorKind.FUNCTION_DECL, - cindex.CursorKind.CXX_METHOD, - cindex.CursorKind.CONSTRUCTOR, - cindex.CursorKind.DESTRUCTOR, - cindex.CursorKind.CONVERSION_FUNCTION, - cindex.CursorKind.FUNCTION_TEMPLATE]: + if is_function: + is_variadic = False + if (cursor.type is not None): + is_variadic = cursor.type.is_function_variadic() + if is_variadic: + args.append("...") args_string = '(' if len(args): args_string += ', '.join(args) @@ -176,40 +200,17 @@ def info(cursor, cindex, settings): if cursor.is_const_method(): declaration_text += " const" # Save declaration text. - popup.__text = DECLARATION_TEMPLATE.format( + return DECLARATION_TEMPLATE.format( type_declaration=markupsafe.escape(declaration_text)) - if settings.show_index_references: - popup.__text += Popup.__lookup_in_sublime_index( - sublime.active_window(), cursor.spelling) - - # Doxygen comments - if cursor.brief_comment: - popup.__text += BRIEF_DOC_TEMPLATE.format( - content=CODE_TEMPLATE.format(lang="", - code=cursor.brief_comment)) - if cursor.raw_comment: - clean_comment = Popup.cleanup_comment(cursor.raw_comment).strip() - print(clean_comment) - if clean_comment: - # Only add this if there is a Doxygen comment. - popup.__text += FULL_DOC_TEMPLATE.format( - content=CODE_TEMPLATE.format(lang="", code=clean_comment)) - # Show macro body - if is_macro: - popup.__text += BODY_TEMPLATE.format( - content=CODE_TEMPLATE.format(lang="c++", - code=macro_parser.body_string)) - # Show type declaration - if settings.show_type_body and body_cursor and body_cursor.extent: - body = Popup.get_text_by_extent(body_cursor.extent) - body = Popup.prettify_body(body) - popup.__text += BODY_TEMPLATE.format( - content=CODE_TEMPLATE.format(lang="c++", code=body)) - return popup - @staticmethod - def __lookup_in_sublime_index(window, spelling): + def info_section_references(cursor, cindex, settings, macro_parser): + """Generate the info text for the declaration.""" + window = sublime.active_window() + spelling = cursor.spelling + if not settings.show_index_references: + return "" + def lookup(lookup_function, spelling): index = lookup_function(spelling) references = [] @@ -224,6 +225,7 @@ def lookup(lookup_function, spelling): line=location.line, col=location.column)) return markupsafe.escape("\n - ".join(references)) + index_references = lookup(window.lookup_symbol_in_index, spelling) usage_references = lookup(window.lookup_symbol_in_open_files, spelling) output_text = "" @@ -235,6 +237,114 @@ def lookup(lookup_function, spelling): references=" - " + usage_references) return output_text + @staticmethod + def info_section_documentation(cursor, cindex, settings, macro_parser): + """Generate text for documentation comment(s), if any.""" + documentation_text = "" + has_comment = None + if macro_parser is not None: + has_comment = macro_parser.doc_string + else: + has_comment = cursor.raw_comment + if has_comment: + if settings.show_doc_as_markdown: + # Doxygen comment: single-line brief description + charset_comment = '/' + '*' + '!' + string.whitespace + brief_comment = has_comment.split("\n")[0] + brief_comment = brief_comment.lstrip(charset_comment) + if len(brief_comment) > 0: + brief_comment = Popup.doxygen_comment(brief_comment) + documentation_text += BRIEF_DOC_TEMPLATE.format( + content=brief_comment) + # Doxygen comment: multi-line detailed description + mdcomment = Popup.cleanup_comment(has_comment) + if len(mdcomment) > 0: + mdcomment = Popup.doxygen_comment(mdcomment) + # Only add this if there is a Doxygen comment. + documentation_text += FULL_DOC_TEMPLATE.format( + content=mdcomment) + else: + # Doxygen comment: single-line brief description + if cursor.brief_comment or (is_macro and has_comment): + documentation_text += BRIEF_DOC_TEMPLATE.format( + content=CODE_TEMPLATE.format(code=cursor.brief_comment, + lang="")) + # Doxygen comment: multi-line detailed description + if cursor.raw_comment or (is_macro and has_comment): + clean_comment = Popup.cleanup_comment(has_comment).strip() + if clean_comment: + # Only add this if there is a Doxygen comment. + documentation_text += FULL_DOC_TEMPLATE.format( + content=CODE_TEMPLATE.format(code=clean_comment, + lang="")) + log.debug("Processed comment:\n" + documentation_text) + return documentation_text + + @staticmethod + def info_section_body(cursor, cindex, settings, macro_parser): + """Generate info text for the "body" section.""" + is_type_decl = cursor.kind in [ + cindex.CursorKind.STRUCT_DECL, + cindex.CursorKind.UNION_DECL, + cindex.CursorKind.CLASS_DECL, + cindex.CursorKind.ENUM_DECL, + cindex.CursorKind.TYPEDEF_DECL, + cindex.CursorKind.TYPE_ALIAS_DECL, + cindex.CursorKind.TYPE_REF + ] + is_function = cursor.kind in [ + cindex.CursorKind.FUNCTION_DECL, + cindex.CursorKind.CXX_METHOD, + cindex.CursorKind.CONSTRUCTOR, + cindex.CursorKind.DESTRUCTOR, + cindex.CursorKind.CONVERSION_FUNCTION, + cindex.CursorKind.FUNCTION_TEMPLATE + ] + body_cursor = None + if is_type_decl: + body_cursor = cursor + elif cursor.kind == cindex.CursorKind.CLASS_TEMPLATE: + body_cursor = cursor.get_definition() + body = "" + # Show macro body + if macro_parser is not None: + body += "#define " + body += cursor.spelling + if (len(macro_parser.args_string) > 0): + body += macro_parser.args_string + else: + body += " " + body += macro_parser.body_string + # Show function declaration + elif settings.show_type_body and is_function: + body += cursor.result_type.spelling + body += " " + body += cursor.spelling + args = [] + for arg in cursor.get_arguments(): + if arg.spelling: + args.append(arg.type.spelling + " " + arg.spelling) + else: + args.append(arg.type.spelling) + if cursor.type is not None and cursor.type.is_function_variadic(): + args.append("...") + body += '(' + if len(args): + body += ', '.join(args) + body += ');' + body = Popup.prettify_body(body) + # Show type declaration + elif settings.show_type_body and body_cursor and body_cursor.extent: + body = Popup.get_text_by_extent(body_cursor.extent) + body = Popup.prettify_body(body) + + # Format into code block with syntax highlighting + if len(body) > 0: + return BODY_TEMPLATE.format( + content=CODE_TEMPLATE.format(lang="c++", code=body)) + else: + return "" + def info_objc(cursor, cindex, settings): """Provide information about Objective C cursors.""" popup = Popup(( @@ -478,7 +588,6 @@ def pop_prepending_empty_lines(lines): break return lines[first_non_empty_line_idx:] - import string lines = raw_comment.split('\n') chars_to_strip = '/' + '*' + '!' + string.whitespace lines = [line.lstrip(chars_to_strip) for line in lines] @@ -495,6 +604,45 @@ def pop_prepending_empty_lines(lines): clean_lines.append(line) return '\n'.join(clean_lines) + @staticmethod + def doxygen_comment(mdcomment): + """Transform cleaned doxygen comment to valid markdown.""" + result = mdcomment + index = mdcomment.find("@param") + if (index >= 0): + result = result[:index] + "\n**Parameters**:\n" + result[index:] + doc_replace = [ + [r'@param\s+([_a-zA-Z0-9.]+)\s*', "- `\\1`: "], + [r'@(retval|returns?)\b\s*', "\n**Returns**:\n"], + [r'@(exception|throws?)\b\s*', "\n**Exceptions**:\n"], + [r'@(sa|see(also)?)\b\s*', "\n**See also**:\n"], + [r'@f\$', "`"], + [r'@[{}]', ""], + ] + for replace in doc_replace: + result = re.sub(replace[0], replace[1], result) + window = sublime.active_window() + + def _make_doxygen_hyperlink(match): + spelling = match.group(1) + if len(spelling) == 0: + return spelling + symbol = window.lookup_symbol_in_index(spelling) + if len(symbol) == 0: + return spelling + location_tuple = symbol[0] + location = IndexLocation(filename=location_tuple[0], + line=location_tuple[2][0], + column=location_tuple[2][1]) + link = Popup.link_from_location(location, spelling, + trailing_space=False) + return link + result = re.sub(r'\b([_a-zA-Z0-9]+)(?=\(\))', + _make_doxygen_hyperlink, result) + result = re.sub(r'#([_a-zA-Z0-9]+)\b', + _make_doxygen_hyperlink, result) + return result + @staticmethod def location_from_type(clang_type): """Return location from type. diff --git a/plugin/settings/settings_storage.py b/plugin/settings/settings_storage.py index 3c03df9b..b746e5ed 100644 --- a/plugin/settings/settings_storage.py +++ b/plugin/settings/settings_storage.py @@ -105,11 +105,13 @@ class SettingsStorage: "max_cache_age", "popup_maximum_height", "popup_maximum_width", + "popup_sections", "progress_style", "show_errors", - "show_index_references", "show_type_body", "show_type_info", + "show_index_references", + "show_doc_as_markdown", "target_compilers", "triggers", "use_default_definitions", diff --git a/plugin/utils/macro_parser.py b/plugin/utils/macro_parser.py index a3ddc1f5..7b34099d 100644 --- a/plugin/utils/macro_parser.py +++ b/plugin/utils/macro_parser.py @@ -1,5 +1,10 @@ """Parse a macro from cindex.""" +import re +import logging + +log = logging.getLogger("ECC") + class MacroParser(object): """Parse info from macros. @@ -21,6 +26,7 @@ def __init__(self, name, location): in a macro with parenthesis, continue parsing into the next line to find it and create a proper args string. """ + self._raw_comment = '' self._args_string = '' self._name = name self._body = '' @@ -38,6 +44,57 @@ def _parse_macro_file_lines(self, macro_file_lines, macro_line_number): macro_line_number (int): line number (1-based) of the macro in macro_file_lines. """ + # parse doxygen comment above macro definition + self._raw_comment = "" + parser_state = 0 + # (parser_state == 0) -> no comment encountered yet + # (parser_state == 1) -> single-line comment encountered + # (parser_state == 2) -> multi-line comment encountered + # (parser_state == 3) -> comment found, finished + lineno = macro_line_number - 1 + while (lineno > 0): + # parse the preceding line of text + lineno -= 1 + if (lineno == 0): + break + prevline = macro_file_lines[lineno].lstrip() + # skip any #if or #ifdef guards before the #define, if applicable + if re.match(r'^[ \t]*#[ \t]*(if|elif|else|ifn?def)\s', prevline): + continue + # parse single-line comments + if (parser_state != 2) and re.match(r'^\s*//', prevline): + parser_state = 1 + if (re.match(r'^\s*//[/!]', prevline)): + self._raw_comment = prevline + self._raw_comment + else: + parser_state = 3 + log.debug("Error while parsing macro doc comment, " + + "found normal single-line comment: " + + self._raw_comment) + parser_state = 0 if len(self._raw_comment) == 0 else 3 + continue + # parse multi-line comments + if (parser_state == 2): + if re.match(r'^\s*/\*', prevline): + if re.match(r'^\s*/\*[\*!]', prevline): + self._raw_comment = prevline + self._raw_comment + else: + log.debug("Error while parsing macro doc comment, " + + "found normal multi-line comment: " + + self._raw_comment) + parser_state = 0 if len(self._raw_comment) == 0 else 3 + else: + self._raw_comment = prevline + self._raw_comment + continue + elif re.match(r'^\s*\*/', prevline): + self._raw_comment = prevline + self._raw_comment + parser_state = 2 + continue + if (len(prevline) > 0): + log.debug("Error while parsing macro doc comment, " + + "found: " + prevline) + break + macro_line = macro_file_lines[macro_line_number - 1].strip() # strip leading '#define' macro_line = macro_line.lstrip('#').lstrip().lstrip('define') @@ -62,7 +119,7 @@ def _parse_macro_file_lines(self, macro_file_lines, macro_line_number): while self._body.endswith("\\"): macro_line_number += 1 line = macro_file_lines[macro_line_number - 1].rstrip() - self._body += "\n" + line + self._body += line @property def args_string(self): @@ -79,3 +136,20 @@ def args_string(self): def body_string(self): """Get macro body string.""" return self._body + + @property + def doc_string(self): + """Get documentation comment string. + + This follows conventional doxygen syntax, + so your comment can use any of these syntaxes: + - /** doc comment (Java style) */ + - /// doc comment (C# style) + - //! doc comment (Qt style), single-line + - /*! doc comment (Qt style), block */ + Conversely, this means that the following comment + syntaxes are NOT valid documentation comments: + - // normal single-line comment + - /* normal block comment */ + """ + return self._raw_comment diff --git a/tests/test_error_vis.py b/tests/test_error_vis.py index 40234419..aad4a1bb 100644 --- a/tests/test_error_vis.py +++ b/tests/test_error_vis.py @@ -245,6 +245,10 @@ def test_info_simple(self): !!! panel-info "ECC: Info" ## Declaration: int [main]({file}:7:5) (int argc, const char *[] argv) + ### Body: + ```c++ + int main(int argc, const char *[] argv); + ``` """.format(file=file_name) self.assertEqual(info_popup.as_markdown(), expected_info_msg) # cleanup @@ -261,6 +265,7 @@ def test_info_no_full(self): self.set_up_view(file_name) completer, settings = self.set_up_completer() settings.show_index_references = False + settings.show_doc_as_markdown = False # Check the current cursor position is completable. self.assertEqual(self.get_row(17), " MyCoolClass cool_class;") pos = self.view.text_point(17, 7) @@ -313,6 +318,7 @@ def test_info_full(self): self.set_up_view(file_name) completer, settings = self.set_up_completer() settings.show_index_references = False + settings.show_doc_as_markdown = False # Check the current cursor position is completable. self.assertEqual(self.get_row(18), " cool_class.foo(2, 2);") pos = self.view.text_point(18, 15) @@ -330,13 +336,17 @@ def test_info_full(self): ``` This is short. ``` - ### Full doxygen comment: + ### Detailed documentation: ``` And this is a full comment. @param[in] a param a @param[in] b param b ``` + ### Body: + ```c++ + void foo(int a, int b); + ``` """.format(file=file_name) # Make sure we remove trailing spaces on the right to comply with how # sublime text handles this. @@ -356,6 +366,7 @@ def test_info_arguments_link(self): self.set_up_view(file_name) completer, settings = self.set_up_completer() settings.show_index_references = False + settings.show_doc_as_markdown = False cursor_row_col = ZeroIndexedRowCol.from_one_indexed( OneIndexedRowCol(10, 15)) # Check the current cursor position is completable. @@ -372,6 +383,10 @@ def test_info_arguments_link(self): !!! panel-info "ECC: Info" ## Declaration: void [foo]({file}:5:8) ([Foo]({file}:1:7) a, [Foo]({file}:1:7) \\* b) + ### Body: + ```c++ + void foo(Foo a, Foo * b); + ``` """.format(file=file_name) # Make sure we remove trailing spaces on the right to comply with how # sublime text handles this. @@ -1191,6 +1206,10 @@ def test_method_with_template_argument(self): ## Declaration: void [foo]({file}:6:8) ([TemplateClass]({file}:3:7)<Foo \ &&, int, 12>) + ### Body: + ```c++ + void foo(TemplateClass); + ``` """ expected_info_msg = fmt.format(file=file_name) # Make sure we remove trailing spaces on the right to comply with how