<filename>core/commands/nimcheck.py
# coding=utf-8
"""Commands and code to check Nim files for errors."""
import os.path
import re
import subprocess
from collections import namedtuple

import sublime
from sublime_plugin import ApplicationCommand, EventListener

from NimLime.core import configuration
from NimLime.core.utils.error_handler import catch_errors
from NimLime.core.utils.misc import (
    busy_frames, display_process_error, get_next_method, loop_status_msg,
    run_process, send_self, trim_region, view_has_nim_syntax
)
from NimLime.core.utils.mixins import NimLimeMixin, NimLimeOutputMixin

# Constants
ERROR_REGION_TAG = 'NimCheckError'
WARN_REGION_TAG = 'NimCheckWarn'
ERROR_REGION_MARK = 'dot'
ERROR_REGION_STYLE = sublime.DRAW_OUTLINED
ERROR_MSG_FORMAT = '({0},{1}): {2}: {3}'.format
MAX_CONTEXT_LINES = 3

MESSAGE_REGEX = re.compile(
    (r"""
        ^
        # File Name
        (?P<file_name> {ANYTHING}+)

        # Line and Column Number
        {SPACE}*
        \(
            (?P<line_number>   {INTEGER}+)
            {SPACE}* {COMMA} {SPACE}+
            (?P<column_number> {INTEGER}+)
        \)

        # Message Type and Content
        {SPACE}*
            (?P<message_type>  {LETTER}+)
            {SPACE}* {COLON} {SPACE}*
            (?P<message>       {ANYTHING}+)

        # Optional Context
        (?P<context> (\n {SPACE}+ {ANYTHING}+)*)
    """.format(
        SPACE    = r'\s',
        INTEGER  = r'\d',
        ANYTHING = r'.',
        COMMA    = r',',
        LETTER   = r'\w',
        COLON    = r':',
    )),
    flags=re.MULTILINE | re.IGNORECASE | re.VERBOSE
)


# ## Functions ## #
NimCheckEntry = namedtuple(
    'NimCheckEntry',
    [
        'file_name',
        'line_number',
        'column_number',
        'message_type',
        'message',
        'entire',
    ]
)


# Functions to run "nim check"
def parse_nimcheck(output):
    entry_list = []
    for match in MESSAGE_REGEX.finditer(output):
        entry = NimCheckEntry(
            file_name     = match.group('file_name'),
            line_number   = int(match.group('line_number')) - 1,
            column_number = int(match.group('column_number')) - 1,
            message_type  = match.group('message_type'),
            message       = match.group('message'),
            entire        = match.group(0),
        )
        entry_list.append(entry)
    return entry_list


@send_self
@catch_errors
def run_nimcheck(file_path, callback, verbosity, disabled_hints, extra_args):
    this = yield
    verbosity_opt = '--verbosity:{}'.format(verbosity)
    hint_opts = (
        '--hint[{}]:off'.format(x) for x in disabled_hints
    )

    command = [configuration.nim_exe, 'check', verbosity_opt]
    command.extend(hint_opts)
    command.extend(extra_args)
    command.append(file_path)

    process, stdout, stderr, error = yield run_process(
        command, this.send,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=os.path.dirname(file_path)
    )

    if error:
        display_process_error(error, 'Nim Check Failed', 'Nim')
        yield callback(None)

    entries = parse_nimcheck(stdout)
    sublime.status_message('run_nimcheck:Nim Check Finished.')

    yield callback(entries)


# Functions to store "nim check" results
class NimCheckViewEntries(EventListener):
    """Contains and cleans up regions created by the 'nim check' commands."""

    entries = {}

    def on_close(self, view):
        self.entries.pop(view.id(), None)


def store_nimcheck_entries(view, errors, warnings):
    view_id = view.id()

    entry_store = {}
    if errors is not None:
        for entry in errors:
            entry_store[entry.line_number] = entry
    if warnings is not None:
        for entry in warnings:
            entry_store[entry.line_number] = entry

    NimCheckViewEntries.entries[view_id] = entry_store


def retrieve_nimcheck_entries(view):
    return NimCheckViewEntries.entries.get(view.id())


def remove_nimcheck_entries(view):
    view.erase_regions(ERROR_REGION_TAG)
    view.erase_regions(WARN_REGION_TAG)
    NimCheckViewEntries.entries.pop(view.id(), None)


# Commands
class NimClearErrors(NimLimeMixin, ApplicationCommand):
    """Clears error and warning marks generated by the Nim check commands."""

    settings_selector = 'check.clear_errors'
    requires_nim_syntax = False

    def __init__(self, *args, **kwargs):
        ApplicationCommand.__init__(self, *args, **kwargs)
        NimLimeMixin.__init__(self, *args, **kwargs)

    @catch_errors
    def run(self, *args, **varargs):
        view = sublime.active_window().active_view()
        remove_nimcheck_entries(view)
        sublime.status_message('Cleared Nim Check Errors & Hints')


class NimDisplayErrorInStatus(EventListener):
    """Displays errors/warnings in the status bar, when a region is clicked."""

    def get_current_entry(self, view):
        selections = view.sel()
        if len(selections) > 1:
            return None

        entries = retrieve_nimcheck_entries(view)
        if entries is None:
            return None

        selected_point = selections[0].end()
        line_number = view.rowcol(selected_point)[0]
        return entries.get(line_number)

    def on_selection_modified(self, view):
        view = sublime.active_window().active_view()
        entry = self.get_current_entry(view)
        if entry is not None:
            message = "NimCheck: " + entry.message
            sublime.status_message(message)


class NimCheckCurrentView(NimLimeOutputMixin, ApplicationCommand):
    """Checks the current Nim file for errors."""

    requires_nim_syntax = True

    settings_selector = 'check.current_file'
    setting_entries = (
        NimLimeOutputMixin.setting_entries,
        ('verbosity', '{0}.verbosity', 2),
        ('disabled_hints', '{0}.disabled_hints', []),
        ('extra_args', '{0}.extra_args', []),
        ('highlight_errors', '{0}.highlight_errors', True),
        ('highlight_warnings', '{0}.highlight_warnings', True),
        ('include_context', '{0}.list.include_context', True),
        ('list_errors', '{0}.list.show_errors', True),
        ('list_warnings', '{0}.list.show_warnings', True),
        ('move_cursor', '{0}.list.move_cursor', True)
    )

    @send_self
    @catch_errors
    def run(self, *args, **varargs):
        this = yield

        window = sublime.active_window()
        view = window.active_view()
        view_name = os.path.split(view.file_name() or view.name())[1]

        remove_nimcheck_entries(view)

        frames = ['Running Nim Check' + f for f in busy_frames]
        stop_status_loop = loop_status_msg(frames, 0.15)

        # Save view text
        if view.is_dirty():
            view.run_command('save')

        # Run 'nim check' on the current view and retrieve the output.
        # project_file = get_nim_project(window, view) or view.file_name()
        entries = yield run_nimcheck(
            file_path      = view.file_name(),
            callback       = this.send,
            verbosity      = self.verbosity,
            disabled_hints = self.disabled_hints,
            extra_args     = self.extra_args
        )
        yield stop_status_loop(get_next_method(this))

        if entries is None:
            sublime.status_message('Nim Check Failed.')
            yield
        sublime.status_message('run:Nim Check Finished.')

        self.highlight_and_list_entries(entries, window, view)

        if self.send_output:
            gen = (m[5] for m in entries if view_name == m[0])
            content = '\n'.join(gen)
            self.write_to_output(content, view)
        yield

    def display_entries(self, view,
                        quick_message_list, point_list,
                        list_entries, highlight_entries):
        entries = []
        region_list = []

        while True:
            entry = yield
            if entry is None:
                yield entries, region_list

            entry_point = view.text_point(
                entry.line_number,
                entry.column_number
            )

            if list_entries:
                quick_message = entry.entire.split('\n')
                if self.include_context:
                    line_count = len(quick_message)
                    del quick_message[MAX_CONTEXT_LINES:line_count]
                    for i in range(line_count, MAX_CONTEXT_LINES):
                        quick_message.append('')
                else:
                    quick_message = quick_message[0]

                entries.append(entry)
                point_list.append(entry_point)
                quick_message_list.append(quick_message)

            # For highlighting
            if highlight_entries:
                message_region = trim_region(view, view.line(entry_point))
                region_list.append(message_region)

    def highlight_and_list_entries(self, entries, window, view):
        """Highlight and list entries gathered from `nim check` output."""
        view_name = os.path.split(view.file_name() or view.name())[1]

        # Instantiate entry list containers
        if self.list_errors or self.list_warnings:
            quick_message_list = []
            point_list = []

        error_entries = self.display_entries(
            view=view,
            quick_message_list=quick_message_list,
            point_list=point_list,
            list_entries=self.list_errors,
            highlight_entries=self.highlight_errors
        )
        warn_entries = self.display_entries(
            view=view,
            quick_message_list=quick_message_list,
            point_list=point_list,
            list_entries=self.list_warnings,
            highlight_entries=self.highlight_warnings
        )

        # Instantiate entry highlighting errors
        error_entries.send(None)
        warn_entries.send(None)
        for entry in entries:

            # makes it work when `--listFullPaths` is in user config
            # TODO: Make this more robust if/when multiple names are allowed,
            # PENDING https://github.com/nim-lang/Nim/pull/8614
            file_name = entry.file_name
            file_name = os.path.basename(file_name)

            if file_name.lower() != view_name.lower():
                continue

            # Determine whether the entry should be highlighted/listed
            if entry.message_type == 'Error':
                error_entries.send(entry)
            else:
                warn_entries.send(entry)

        error_entries, error_region_list = error_entries.send(None)
        warn_entries, warn_region_list = warn_entries.send(None)
        store_nimcheck_entries(view, warn_entries, error_entries)

        if error_region_list:
            view.add_regions(
                ERROR_REGION_TAG,
                error_region_list,
                'invalid.illegal',
                ERROR_REGION_MARK,
                ERROR_REGION_STYLE
            )

        if warn_region_list:
            view.add_regions(
                WARN_REGION_TAG,
                warn_region_list,
                'invalid.deprecated',
                ERROR_REGION_MARK,
                ERROR_REGION_STYLE
            )

        if self.list_errors or self.list_warnings:
            def _goto_error(choice):
                if choice != -1:
                    chosen_point = point_list[choice]
                    view.show(chosen_point)
                    if self.move_cursor:
                        view.sel().clear()
                        view.sel().add(sublime.Region(chosen_point))

            flag = 0
            if self.include_context:
                flag = sublime.MONOSPACE_FONT

            window.show_quick_panel(quick_message_list, _goto_error, flag)


class NimCheckOnSaveListener(NimCheckCurrentView, EventListener):
    """Runs the Nim Check command when the current file is saved."""

    settings_selector = 'check.on_save'

    def on_post_save(self, view):
        view = sublime.active_window().active_view()
        if self.enabled and view_has_nim_syntax(view):
            self.run()

<filename>core/commands/nimcheck.py
# coding=utf-8
"""Commands and code to check Nim files for errors."""
import os.path
import re
import subprocess
from collections import namedtuple

import sublime
from sublime_plugin import ApplicationCommand, EventListener

from NimLime.core import configuration
from NimLime.core.utils.error_handler import catch_errors
from NimLime.core.utils.misc import (
    busy_frames, display_process_error, get_next_method, loop_status_msg,
    run_process, send_self, trim_region, view_has_nim_syntax
)
from NimLime.core.utils.mixins import NimLimeMixin, NimLimeOutputMixin

# Constants
ERROR_REGION_TAG = 'NimCheckError'
WARN_REGION_TAG = 'NimCheckWarn'
ERROR_REGION_MARK = 'dot'
ERROR_REGION_STYLE = sublime.DRAW_OUTLINED
ERROR_MSG_FORMAT = '({0},{1}): {2}: {3}'.format
MAX_CONTEXT_LINES = 3

MESSAGE_REGEX = re.compile(
    (r"""
        ^
        # File Name
        (?P<file_name> {ANYTHING}+)

        # Line and Column Number
        {SPACE}*
        \(
            (?P<line_number>   {INTEGER}+)
            {SPACE}* {COMMA} {SPACE}+
            (?P<column_number> {INTEGER}+)
        \)

        # Message Type and Content
        {SPACE}*
            (?P<message_type>  {LETTER}+)
            {SPACE}* {COLON} {SPACE}*
            (?P<message>       {ANYTHING}+)

        # Optional Context
        (?P<context> (\n {SPACE}+ {ANYTHING}+)*)
    """.format(
        SPACE    = r'\s',
        INTEGER  = r'\d',
        ANYTHING = r'.',
        COMMA    = r',',
        LETTER   = r'\w',
        COLON    = r':',
    )),
    flags=re.MULTILINE | re.IGNORECASE | re.VERBOSE
)


# ## Functions ## #
NimCheckEntry = namedtuple(
    'NimCheckEntry',
    [
        'file_name',
        'line_number',
        'column_number',
        'message_type',
        'message',
        'entire',
    ]
)


# Functions to run "nim check"
def parse_nimcheck(output):
    entry_list = []
    for match in MESSAGE_REGEX.finditer(output):
        entry = NimCheckEntry(
            file_name     = match.group('file_name'),
            line_number   = int(match.group('line_number')) - 1,
            column_number = int(match.group('column_number')) - 1,
            message_type  = match.group('message_type'),
            message       = match.group('message'),
            entire        = match.group(0),
        )
        entry_list.append(entry)
    return entry_list


@send_self
@catch_errors
def run_nimcheck(file_path, callback, verbosity, disabled_hints, extra_args):
    this = yield
    verbosity_opt = '--verbosity:{}'.format(verbosity)
    hint_opts = (
        '--hint[{}]:off'.format(x) for x in disabled_hints
    )

    command = [configuration.nim_exe, 'check', verbosity_opt]
    command.extend(hint_opts)
    command.extend(extra_args)
    command.append(file_path)

    process, stdout, stderr, error = yield run_process(
        command, this.send,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=os.path.dirname(file_path)
    )

    if error:
        display_process_error(error, 'Nim Check Failed', 'Nim')
        yield callback(None)

    entries = parse_nimcheck(stdout)
    sublime.status_message('run_nimcheck:Nim Check Finished.')

    yield callback(entries)


# Functions to store "nim check" results
class NimCheckViewEntries(EventListener):
    """Contains and cleans up regions created by the 'nim check' commands."""

    entries = {}

    def on_close(self, view):
        self.entries.pop(view.id(), None)


def store_nimcheck_entries(view, errors, warnings):
    view_id = view.id()

    entry_store = {}
    if errors is not None:
        for entry in errors:
            entry_store[entry.line_number] = entry
    if warnings is not None:
        for entry in warnings:
            entry_store[entry.line_number] = entry

    NimCheckViewEntries.entries[view_id] = entry_store


def retrieve_nimcheck_entries(view):
    return NimCheckViewEntries.entries.get(view.id())


def remove_nimcheck_entries(view):
    view.erase_regions(ERROR_REGION_TAG)
    view.erase_regions(WARN_REGION_TAG)
    NimCheckViewEntries.entries.pop(view.id(), None)


# Commands
class NimClearErrors(NimLimeMixin, ApplicationCommand):
    """Clears error and warning marks generated by the Nim check commands."""

    settings_selector = 'check.clear_errors'
    requires_nim_syntax = False

    def __init__(self, *args, **kwargs):
        ApplicationCommand.__init__(self, *args, **kwargs)
        NimLimeMixin.__init__(self, *args, **kwargs)

    @catch_errors
    def run(self, *args, **varargs):
        view = sublime.active_window().active_view()
        remove_nimcheck_entries(view)
        sublime.status_message('Cleared Nim Check Errors & Hints')


class NimDisplayErrorInStatus(EventListener):
    """Displays errors/warnings in the status bar, when a region is clicked."""

    def get_current_entry(self, view):
        selections = view.sel()
        if len(selections) > 1:
            return None

        entries = retrieve_nimcheck_entries(view)
        if entries is None:
            return None

        selected_point = selections[0].end()
        line_number = view.rowcol(selected_point)[0]
        return entries.get(line_number)

    def on_selection_modified(self, view):
        view = sublime.active_window().active_view()
        entry = self.get_current_entry(view)
        if entry is not None:
            message = "NimCheck: " + entry.message
            sublime.status_message(message)


class NimCheckCurrentView(NimLimeOutputMixin, ApplicationCommand):
    """Checks the current Nim file for errors."""

    requires_nim_syntax = True

    settings_selector = 'check.current_file'
    setting_entries = (
        NimLimeOutputMixin.setting_entries,
        ('verbosity', '{0}.verbosity', 2),
        ('disabled_hints', '{0}.disabled_hints', []),
        ('extra_args', '{0}.extra_args', []),
        ('highlight_errors', '{0}.highlight_errors', True),
        ('highlight_warnings', '{0}.highlight_warnings', True),
        ('include_context', '{0}.list.include_context', True),
        ('list_errors', '{0}.list.show_errors', True),
        ('list_warnings', '{0}.list.show_warnings', True),
        ('move_cursor', '{0}.list.move_cursor', True)
    )

    @send_self
    @catch_errors
    def run(self, *args, **varargs):
        this = yield

        window = sublime.active_window()
        view = window.active_view()
        view_name = os.path.split(view.file_name() or view.name())[1]

        remove_nimcheck_entries(view)

        frames = ['Running Nim Check' + f for f in busy_frames]
        stop_status_loop = loop_status_msg(frames, 0.15)

        # Save view text
        if view.is_dirty():
            view.run_command('save')

        # Run 'nim check' on the current view and retrieve the output.
        # project_file = get_nim_project(window, view) or view.file_name()
        entries = yield run_nimcheck(
            file_path      = view.file_name(),
            callback       = this.send,
            verbosity      = self.verbosity,
            disabled_hints = self.disabled_hints,
            extra_args     = self.extra_args
        )
        yield stop_status_loop(get_next_method(this))

        if entries is None:
            sublime.status_message('Nim Check Failed.')
            yield
        sublime.status_message('run:Nim Check Finished.')

        self.highlight_and_list_entries(entries, window, view)

        if self.send_output:
            gen = (m[5] for m in entries if view_name == m[0])
            content = '\n'.join(gen)
            self.write_to_output(content, view)
        yield

    def display_entries(self, view,
                        quick_message_list, point_list,
                        list_entries, highlight_entries):
        entries = []
        region_list = []

        while True:
            entry = yield
            if entry is None:
                yield entries, region_list

            entry_point = view.text_point(
                entry.line_number,
                entry.column_number
            )

            if list_entries:
                quick_message = entry.entire.split('\n')
                if self.include_context:
                    line_count = len(quick_message)
                    del quick_message[MAX_CONTEXT_LINES:line_count]
                    for i in range(line_count, MAX_CONTEXT_LINES):
                        quick_message.append('')
                else:
                    quick_message = quick_message[0]

                entries.append(entry)
                point_list.append(entry_point)
                quick_message_list.append(quick_message)

            # For highlighting
            if highlight_entries:
                message_region = trim_region(view, view.line(entry_point))
                region_list.append(message_region)

    def highlight_and_list_entries(self, entries, window, view):
        """Highlight and list entries gathered from `nim check` output."""
        view_name = os.path.split(view.file_name() or view.name())[1]

        # Instantiate entry list containers
        if self.list_errors or self.list_warnings:
            quick_message_list = []
            point_list = []

        error_entries = self.display_entries(
            view=view,
            quick_message_list=quick_message_list,
            point_list=point_list,
            list_entries=self.list_errors,
            highlight_entries=self.highlight_errors
        )
        warn_entries = self.display_entries(
            view=view,
            quick_message_list=quick_message_list,
            point_list=point_list,
            list_entries=self.list_warnings,
            highlight_entries=self.highlight_warnings
        )

        # Instantiate entry highlighting errors
        error_entries.send(None)
        warn_entries.send(None)
        for entry in entries:

            # makes it work when `--listFullPaths` is in user config
            # TODO: Make this more robust if/when multiple names are allowed,
            # PENDING https://github.com/nim-lang/Nim/pull/8614
            file_name = entry.file_name
            file_name = os.path.basename(file_name)

            if file_name.lower() != view_name.lower():
                continue

            # Determine whether the entry should be highlighted/listed
            if entry.message_type == 'Error':
                error_entries.send(entry)
            else:
                warn_entries.send(entry)

        error_entries, error_region_list = error_entries.send(None)
        warn_entries, warn_region_list = warn_entries.send(None)
        store_nimcheck_entries(view, warn_entries, error_entries)

        if error_region_list:
            view.add_regions(
                ERROR_REGION_TAG,
                error_region_list,
                'invalid.illegal',
                ERROR_REGION_MARK,
                ERROR_REGION_STYLE
            )

        if warn_region_list:
            view.add_regions(
                WARN_REGION_TAG,
                warn_region_list,
                'invalid.deprecated',
                ERROR_REGION_MARK,
                ERROR_REGION_STYLE
            )

        if self.list_errors or self.list_warnings:
            def _goto_error(choice):
                if choice != -1:
                    chosen_point = point_list[choice]
                    view.show(chosen_point)
                    if self.move_cursor:
                        view.sel().clear()
                        view.sel().add(sublime.Region(chosen_point))

            flag = 0
            if self.include_context:
                flag = sublime.MONOSPACE_FONT

            window.show_quick_panel(quick_message_list, _goto_error, flag)


class NimCheckOnSaveListener(NimCheckCurrentView, EventListener):
    """Runs the Nim Check command when the current file is saved."""

    settings_selector = 'check.on_save'

    def on_post_save(self, view):
        view = sublime.active_window().active_view()
        if self.enabled and view_has_nim_syntax(view):
            self.run()

# coding=utf-8 Commands and code to check Nim files for errors. # Constants ^         # File Name         (?P<file_name> {ANYTHING}+)          # Line and Column Number         {SPACE}*         \(             (?P<line_number>   {INTEGER}+)             {SPACE}* {COMMA} {SPACE}+             (?P<column_number> {INTEGER}+)         \)          # Message Type and Content         {SPACE}*             (?P<message_type>  {LETTER}+)             {SPACE}* {COLON} {SPACE}*             (?P<message>       {ANYTHING}+)          # Optional Context         (?P<context> (\n {SPACE}+ {ANYTHING}+)*) # ## Functions ## # # Functions to run "nim check" # Functions to store "nim check" results Contains and cleans up regions created by the 'nim check' commands. # Commands Clears error and warning marks generated by the Nim check commands. Displays errors/warnings in the status bar, when a region is clicked. Checks the current Nim file for errors. # Save view text # Run 'nim check' on the current view and retrieve the output. # project_file = get_nim_project(window, view) or view.file_name() # For highlighting Highlight and list entries gathered from `nim check` output. # Instantiate entry list containers # Instantiate entry highlighting errors # makes it work when `--listFullPaths` is in user config # TODO: Make this more robust if/when multiple names are allowed, # PENDING https://github.com/nim-lang/Nim/pull/8614 # Determine whether the entry should be highlighted/listed Runs the Nim Check command when the current file is saved.