Sublime Forum

Clear build output before displaying in new result

#1

Before I start. I marked some sections with #NUMBER to give you a clue that there is more info about it.

Description:

I have setup a typescript project(#1) and a build command that executes this npx tsc --noEmit --skipLibCheck --watch. (#2)

When I run that build:

2020-06-03-151625_669x476_scrot

I will get the error.
So far so good.
But If I correct the error

2020-06-03-151728_669x476_scrot

NOTE: 3:17:23 PM - Found 0 errors. Watching for file changes.
tsc reported 0 errors, so I expect to see no errors in the view, but I still do.

That is because we still have index.ts(1,5): error TS2322: Type '21' is not assignable to type 'string'. in the build output panel.

The npx tsc --noEmit --skipLibCheck --watch behaves differently when executed via the sublime build, than when executed from the terminal.

When executed via the terminal the previous tsc output is cleared before the new results are piped in.
When executed via sublime build output panel, the output is not cleared before piping in new results.

Here is a gif demonstrating all of this:

Question:

How can I configure the build panel to clear the previous results?

#1

Steps to reproduction:

  1. mkdir project
  2. cd project
  3. npm init
  4. npm install typescript --save-dev
  5. create a file index.ts with the following content:
let x: string = 21;
// this file contains an error on purpose

Given I have created a build file:

TypeScript-TypeCheck.sublime-build

{
    "cmd": ["npx", "tsc", "--noEmit", "--skipLibCheck", "--watch"],
    "file_regex": "(.*\\.ts?)\\(([0-9]+)\\,([0-9]+)\\)\\:\\s(...*?)$",
    "selector": "source.js.typescript",
}

NOTE: Change the selector to match your typescript syntax selector, usually it is source.ts. (I use JSCustom, that is the reason why the selector is source.js.typescript)

#2

npx tsc - will execute the locally(in project node_modules) installed typescript compiler.
–noEmit - will emit no files(will not compile/create new files, it will just do a type check, because that is just what I want).
–skipLibCheck - will only check my code, tsc will finish faster with this flag.
– watch - will continue to run the process and watch on file changes to report back the new errors/warnings. (without me needing to execute the build again).

0 Likes

How to cancel previously started builds
#2

The reason why it doesn’t clear the output is because the exec command appends the result of stdout to the previous results, whereas I guess there is some magic going on in the typescript compiler to clear the previous results in the command prompt so you see the results of only the latest compilation, whereas with the Sublime’s build system, you see the results of every TypeScript compilation appended to the previous one and put in the output_panel of exec.

One solution I could think of is to write a custom patch for the exec command and override the method where the previous outputs are appended such that only the results of the latest compilation are appended.

import re
import sublime
import sublime_plugin

from Default.exec import ExecCommand

class CustomExecPatchCommand(ExecCommand, sublime_plugin.WindowCommand):

	def run(self, **kwargs):
		super().run(**kwargs)

	def service_text_queue(self):
		is_empty = False
		with self.text_queue_lock:
			if len(self.text_queue) == 0:
				# this can happen if a new build was started, which will clear
				# the text_queue
				return

			characters = self.text_queue.popleft()
			is_empty = (len(self.text_queue) == 0)

		# Before the new output is appended, clear anything appended already.
		self.output_view.run_command("select_all")
		no_of_errors = len(re.findall(r"\berror\b", characters))
		for i in range(0, no_of_errors):
			self.output_view.run_command("move", {
				"by": "lines",
				"forward": False,
				"extend": True 
			})
		self.output_view.run_command("left_delete")

		# Here's where the new stdout results gets appended.
		self.output_view.run_command(
			'append',
			{'characters': characters, 'force': True, 'scroll_to_end': True})

		if self.show_errors_inline and characters.find('\n') >= 0:
			errs = self.output_view.find_all_results_with_text()
			errs_by_file = {}
			for file, line, column, text in errs:
				if file not in errs_by_file:
					errs_by_file[file] = []
				errs_by_file[file].append((line, column, text))
			self.errs_by_file = errs_by_file

			self.update_phantoms()

		if not is_empty:
			sublime.set_timeout(self.service_text_queue, 1)

With this in place, you should now be able to define the custom target of custom_exec_patch for your TypeScript build system.

Here is a GIF of it in action :-

There are some hiccups with this as I have not spent a lot of time on it but you get the general idea.

This for ST3 but if you are on ST4, you would need to see how to do this for the exec.py for ST4 (in case if there are any changes).

Hopefully, this helps a little bit.

1 Like

#3

Thanks for the detailed response
and thanks for taking the time to look at it :slight_smile:
I will give it a go.

I guess there is some magic going on in the typescript compiler to clear the previous results in the command prompt so you see the results of only the latest compilation

If you see the images in the sublime build panel above, You will see a strange character <0x1b>. That character probably tells the terminal to clear the prompt before piping in the new text.(I guess)

0 Likes

#4

For what it’s worth, you can also use the Terminus package as a custom target in a build; as a terminal emulator it should behave the same as a standard terminal/command prompt would.

1 Like

#5

Thanks for suggesting Terminus. Just tired it :slight_smile:

Here are few things I noticed. The output panel is cleared when new text comes in, which is great.
But the next_result and prev_result commands are not working like they do with a build panel, which I use a lot. Pressing them don’t take me to the location where the error is.

And there is no inline error phantom which is nice to have. :slight_smile:

0 Likes

#6

I have written a build command based on the docs https://www.sublimetext.com/docs/3/build_systems.html :slight_smile:

Mostly …, well … everything is copy/pasted from the docs, and the Default.exec file :sunglasses:

import sublime
import sublime_plugin

import subprocess
import threading
import os
import html


class MyExampleBuildCommand(sublime_plugin.WindowCommand):
    encoding = 'utf-8'
    killed = False
    proc = None
    panel = None
    panel_lock = threading.Lock()
    errs_by_file = {}

    def is_enabled(self, kill=False):
        # The Cancel build option should only be available
        # when the process is still running
        if kill:
            return self.proc is not None and self.proc.poll() is None
        return True

    def run(self, cmd=[], file_regex="", line_regex="", kill=False):
        if kill:
            if self.proc:
                self.killed = True
                self.proc.terminate()
            return

        vars = self.window.extract_variables()
        working_dir = vars['file_path']

        # A lock is used to ensure only one thread is
        # touching the output panel at a time
        with self.panel_lock:
            # Creating the panel implicitly clears any previous contents
            self.panel = self.window.create_output_panel('exec')

            # Enable result navigation. The result_file_regex does
            # the primary matching, but result_line_regex is used
            # when build output includes some entries that only
            # contain line/column info beneath a previous line
            # listing the file info. The result_base_dir sets the
            # path to resolve relative file names against.
            settings = self.panel.settings()
            settings.set(
                'result_file_regex',
                r'{}'.format(file_regex)
            )
            settings.set(
                'result_line_regex',
                r'{}'.format(line_regex)
            )
            settings.set('result_base_dir', working_dir)
            settings.set("scroll_past_end", False)

            self.window.run_command('show_panel', {'panel': 'output.exec'})

        if self.proc is not None:
            self.proc.terminate()
            self.proc = None

        self.proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            cwd=working_dir
        )
        self.killed = False

        threading.Thread(
            target=self.read_handle,
            args=(self.proc.stdout,)
        ).start()

    def read_handle(self, handle):
        chunk_size = 2 ** 13
        out = b''
        while True:
            try:
                data = os.read(handle.fileno(), chunk_size)
                # If exactly the requested number of bytes was
                # read, there may be more data, and the current
                # data may contain part of a multibyte char
                out += data
                if len(data) == chunk_size:
                    continue
                if data == b'' and out == b'':
                    raise IOError('EOF')
                # We pass out to a function to ensure the
                # timeout gets the value of out right now,
                # rather than a future (mutated) version
                self.queue_write(out.decode(self.encoding))
                if data == b'':
                    raise IOError('EOF')
                out = b''
            except (UnicodeDecodeError) as e:
                msg = 'Error decoding output using %s - %s'
                self.queue_write(msg  % (self.encoding, str(e)))
                break
            except (IOError):
                if self.killed:
                    msg = 'Cancelled'
                else:
                    msg = 'Finished'
                self.queue_write('\n[%s]' % msg)
                break

    def queue_write(self, text):
        sublime.set_timeout(lambda: self.do_write(text), 1)

    def do_write(self, text):
        with self.panel_lock:
            if 'e' in text:
                text = text.replace('e', '')
                self.panel.run_command('clear_view_content')
            self.panel.run_command('append', {'characters': text})

        # Updating annotations is expensive, so batch it to the main thread
        def annotations_check():
            errs = self.panel.find_all_results_with_text()
            if not len(errs):
                self.hide_annotations()
                return
            errs_by_file = {}
            for file, line, column, text in errs:
                if file not in errs_by_file:
                    errs_by_file[file] = []
                errs_by_file[file].append((line, column, text))
            self.errs_by_file = errs_by_file

            self.update_annotations()

        # if text.find('\n') >= 0:
        sublime.set_timeout(lambda: annotations_check())

    def update_annotations(self):
        stylesheet = '''
            <style>
                #annotation-error {
                    background-color: color(var(--background) blend(#fff 95%));
                }
                html.dark #annotation-error {
                    background-color: color(var(--background) blend(#fff 95%));
                }
                html.light #annotation-error {
                    background-color: color(var(--background) blend(#000 85%));
                }
                a {
                    text-decoration: inherit;
                }
            </style>
        '''

        for file, errs in self.errs_by_file.items():
            view = self.window.find_open_file(file)
            if view:
                selection_set = []
                content_set = []

                line_err_set = []

                for line, column, text in errs:
                    pt = view.text_point(line - 1, column - 1)
                    if (line_err_set and
                            line == line_err_set[len(line_err_set) - 1][0]):
                        line_err_set[len(line_err_set) - 1][1] += (
                            "<br>" + html.escape(text, quote=False))
                    else:
                        pt_b = pt + 1
                        if view.classify(pt) & sublime.CLASS_WORD_START:
                            pt_b = view.find_by_class(
                                pt,
                                forward=True,
                                classes=(sublime.CLASS_WORD_END))
                        if pt_b <= pt:
                            pt_b = pt + 1
                        selection_set.append(
                            sublime.Region(pt, pt_b))
                        line_err_set.append(
                            [line, html.escape(text, quote=False)])

                for text in line_err_set:
                    content_set.append(
                        '<body>' + stylesheet +
                        '<div class="error" id=annotation-error>' +
                        '<span class="content">' + text[1] + '</span></div>' +
                        '</body>')

                view.add_regions(
                    "exec",
                    selection_set,
                    scope="invalid",
                    annotations=content_set,
                    flags=(sublime.DRAW_SQUIGGLY_UNDERLINE |
                           sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE),
                    on_close=self.hide_annotations)

    def hide_annotations(self):
        for window in sublime.windows():
            for file, errs in self.errs_by_file.items():
                view = window.find_open_file(file)
                if view:
                    view.erase_regions("exec")
                    view.hide_popup()

        view = sublime.active_window().active_view()
        if view:
            view.erase_regions("exec")
            view.hide_popup()

        self.errs_by_file = {}
        self.annotation_sets_by_buffer = {}
        self.show_errors_inline = False


class ClearViewContent(sublime_plugin.TextCommand):
    def run(self, edit):
        self.view.erase(edit, sublime.Region(0, self.view.size()))

Here is how my sublime-build file looks,

{
    "target": "my_example_build",
    "cmd": ["npx", "tsc", "--noEmit", "--skipLibCheck", "--watch"],
    "cancel": {"kill": true},
    "file_regex": "(.*\\.ts?)\\(([0-9]+)\\,([0-9]+)\\)\\:.*?:\\s(...*?)$",
    "selector": "source.js.typescript",
}

Btw, I no longer have the problem of previous builds being in memory.


because of this check:

class MyExampleBuildCommand(sublime_plugin.WindowCommand):
    ...
    proc = None

    def run(self, cmd=[], file_regex="", line_regex="", kill=False):
        if kill:
            if self.proc:
                self.killed = True
                self.proc.terminate()

Plus, I plan to listen on the on_exit hook to kill the process if it is still alive.

So thanks all for the help :+1:

0 Likes