diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 92b97bbe..9854ee82 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,11 +3,13 @@ name: Syntax Tests on: push: paths: + - '.github/workflows/main.yml' - '**.sublime-syntax' - '**/syntax_test*' - '**.tmPreferences' pull_request: paths: + - '.github/workflows/main.yml' - '**.sublime-syntax' - '**/syntax_test*' - '**.tmPreferences' @@ -20,6 +22,6 @@ jobs: - uses: actions/checkout@v2 - uses: SublimeText/syntax-test-action@v2 with: - build: 4113 - default_packages: master + build: 4192 + default_packages: v4192 package_name: ElixirSyntax diff --git a/BACKLOG.md b/BACKLOG.md index f0818dc5..8a237fa6 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,8 +2,6 @@ The backlog contains tasks that are planned to be worked on or ideas that may be useful. +* EEx/HEEx: fix matching EEx tags inside `script` and `style` tags, the CSS `style=""` and the JS `on...=""` attributes. * Elixir: match do-block after function call: `func(a, b) do end`. -* Elixir: allow setting the seed used for `mix test` via a command or similar. -* Elixir: use ST4's branching feature to improve some rules. -* Elixir: try to fix the rules that don't work due to the "context sanity limit" error. -* Elixir: highlight `:atom`, `identifier`, `Module.X`, `M.func/1` in Markdown comments? +* Elixir: use ST4's branching feature to improve syntax matching rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7966c5af..84509238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,129 @@ # Changelog +## [v4.1.0] – 2025-03-28 + +- HEEx: support new syntax for embedding Elixir code with curly braces. + +## [v4.0.0] – 2024-09-01 + +- Elixir: improved matching of right-arrow clauses. +- Elixir: recognize SQL strings inside `query("...")`, `query(Repo, "...")`, `query_many("...")`, `query_many(Repo, "...")` (including bang versions). +- Elixir: fixed expressions in struct headers, e.g.: `%^module{}` and `%@module{}`. +- Elixir: recognize all variants of atom word strings, e.g.: `~w"one two three"a` +- Elixir: fixes to capture expressions: `& 1` is a capture with an integer, not the capture argument `&1`. `& &1.func/2`, `&var.member.func/3` and `&@module.func/1` are captured remote functions. +- HEEx: recognize special attributes `:let`, `:for` and `:if`. +- HEEx: fixed matching dynamic attributes, e.g.: `
`. +- Commands: `mix_test` is better at finding the root `mix.exs` file and runs when the project hasn't been built yet. +- Commands: `mix test` and `mix format` error locations can be double-clicked and jumped to. +- Commands: read `mix` output unbuffered for immediate display in the output panel. +- Commands: removed the `output_scroll_time` setting. The output will scroll automatically without delay. +- Commands: run `mix test` with selected lines if no standard `test` blocks were found, allowing to run tests defined by macros such as `property/2`. +- Commands: prevent executing `mix test` again if it's already running. +- Completions: use double quotes instead of curly braces for `phx` attributes. + +## [v3.2.3] – 2023-08-13 + +- EEx, HEEx: use `<%!-- ... --%>` when toggling comments. +- EEx, HEEx, Surface: highlight begin and end punctuation marks of comments. +- Commands: fix: filter out already selected tests when using multi-cursor selections. + +## [v3.2.2] – 2023-06-28 + +- Elixir: fixed module function call regression in captures (`&Map.take(&1, @fields)`). +- Elixir: recognize special macro `defmacro (..) do end`. +- Commands: added `mix_test_hide_panel` command. + +## [v3.2.1] – 2023-06-24 + +- Elixir: fixed quoted module name function calls such as `:"Elixir.Kernel".in(1, [1])`. +- SQL: recognize `CREATE TYPE`. + +## [v3.2.0] – 2023-05-02 + +- Commands: improved/generalized syntax detection for enabling/disabling commands. +- Commands: fix: output both stdout/stderr when running `mix format`/`mix test`. +- Commands: auto-scroll `mix format` output when it's compiling. +- SQL: recognize `FILTER` in `array_agg(x) FILTER (...)`. + +## [v3.1.5] – 2023-04-30 + +- Elixir: recognize `name` in `defmodule name do end`. +- Commands: fix: print `mix format` error output asynchronously. +- Commands: fix: hide the `mix format` error panel when the command is successful again. + +## [v3.1.4] – 2022-12-21 + +- Commands: fix: call `mix format` asynchronously to avoid locking up the view. + +## [v3.1.3] – 2022-12-15 + +- Package: fix: added `dependencies.json` to require the `pathlib` library (#53). + +## [v3.1.2] – 2022-12-13 + +- Commands: recognize more file types to allow running `mix format` on. +- Commands: mention possibly unsaved changes when a test wasn't found. + +## [v3.1.1] – 2022-11-08 + +- Commands: fixed `mix format` and `mix test` in non-project windows. +- Commands: fixed finding counterpart of a test/code file in non-project windows. +- Commands: ignore `.elixir_ls`, `_build` and `deps` folders when searching for the counterpart of a test/code file. + +## [v3.1.0] – 2022-11-03 + +- Commands: added `mix_test_show_panel`, `mix_test_switch_to_code_or_test`, `search_hex_packages` and `open_hex_docs`. + + `Mix Test: Show Panel` reopens the test output panel if closed. + + `Mix Test: Switch to Code or Test` jumps to the corresponding code file of a test and vice versa. + + `ElixirSyntax: Open Hex Docs` displays a filterable list of all available projects on hexdocs.pm. + + `ElixirSyntax: Search Hex Packages` searches for packages on hex.pm and displays the results in a list. +- Palette: added `Mix Test: All`. +- Palette: renamed caption `Mix Test: Set Seed` to `Mix Test: Set --seed`. + +## [v3.0.0] – 2022-10-24 + +- Elixir: removed Markdown highlighting from doc comments due to unfixable issues. +- Elixir: properly highlight arrow clauses in `for`-statement arguments. +- Elixir: match macro and record calls inside parameters and arrow clauses (`for module(module: module) <- all_modules`). +- Elixir: fixed stepped ranges as parameters (`first..last//step`). +- Elixir: fixed string interpolations clearing all scopes (`"2^8 = #{2 ** 8}"`). +- Commands: added Python code to be able to call `mix test` in various ways. +- Commands: added `mix_format_project` and `mix_format_file` commands with auto-format setting. +- Palette: added `Mix Test: ...` and `Mix Format: ...` commands. +- EEx: added syntax file for EEx in plain text files. +- HTML (EEx), Elixir (EEx): added `<%!-- ... --%>` multi-line comments. +- HTML (EEx): match EEx tags as tag and attribute names (`([^<]*)
+ ''', + package_list_match.group(1) + ) + + previous_results = kwargs.get('previous_results') or [] + + results = previous_results + [ + {'name': m[2], 'desc': m[4], 'version': m[3], 'recent_dls': m[0], 'total_dls': m[1], + 'url': HEX_URL + '/packages/' + m[2]} + for m in package_matches + ] + + selectable_results = results + [ + {'label': 'Open search query in browser', 'url': query_url, 'desc': 'Terms: %s' % query}, + ] + ( + next_page and [{ + 'label': 'Load page %d' % next_page, + 'page': next_page, + 'desc': 'Total packages found: %s' % (total_packages_count or 'unknown') + }] or [] + ) + + def on_select(i): + if i >= 0: + result = selectable_results[i] + if result.get('page'): + print_status_msg('Loading page %d on hex.pm for %r' % (next_page, query)) + cb = lambda: search_hex_pm(window, query, page=next_page, previous_results=results) + sublime.set_timeout_async(cb) + else: + webbrowser.open_new_tab(result['url']) + + placeholder = 'Open a project in the web browser.' + selected_index = len(previous_results) if previous_results else -1 + + result_items = [ + sublime.QuickPanelItem( + trigger=result.get('label') or '%s v%s' % (result['name'], result['version']), + details=result.get('desc') or '', + annotation=result.get('recent_dls') \ + and '%s recent / %s total downloads' % (result['recent_dls'], result['total_dls']) \ + or '', + kind=result.get('recent_dls') and sublime.KIND_NAVIGATION or sublime.KIND_AMBIGUOUS + ) + for result in selectable_results + ] + + window.show_quick_panel(result_items, on_select, + placeholder=placeholder, selected_index=selected_index + ) + +def fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path): + """ Fetches, parses and saves the sitemap items in a JSON file. """ + etag = next( + (value for (header, value) in resp.headers.items() if header.lower() == 'etag'), None + ) + + sitemap_xml = resp.read().decode('utf-8') + elixir_core_projects = [(name, None) for name in ELIXIR_CORE_APP_NAMES] + hexdocs_projects = re.findall(name_lastmod_rx, sitemap_xml) + young_projects, old_projects, now = [], [], datetime.now() + + for name, date in hexdocs_projects: + parsed_date = datetime.strptime(date[:10], '%Y-%m-%d') + younger_than_x_days = (now - parsed_date).days <= PROJECT_MAX_AGE_DAYS + (young_projects if younger_than_x_days else old_projects).append((name, date)) + + projects = sorted(young_projects + elixir_core_projects) + old_projects + projects = [{'name': name, 'lastmod': lastmod} for (name, lastmod) in projects] + sitemap_dict = {'projects': projects, 'etag': etag} + save_json_file(cached_sitemap_json_path, sitemap_dict) + + return sitemap_dict + +def show_hexdocs_list(window, projects): + """ Shows the hexdocs projects in a quick panel overlay. """ + project_items = [ + sublime.QuickPanelItem( + trigger=project['name'], + details=project['lastmod'] \ + and 'Last modified: %s' % project['lastmod'][:-4].replace('T', ' ') \ + or '', + kind=sublime.KIND_NAVIGATION + ) + for project in projects + ] + + def on_select(i): + i >= 0 and webbrowser.open_new_tab(HEXDOCS_URL + '/' + projects[i]['name']) + + placeholder = 'Open a project\'s documentation in the web browser.' + window.show_quick_panel(project_items, on_select, placeholder=placeholder) diff --git a/commands/mix_format.py b/commands/mix_format.py new file mode 100644 index 00000000..1251ae82 --- /dev/null +++ b/commands/mix_format.py @@ -0,0 +1,126 @@ +import sublime +import sublime_plugin +import subprocess +import shlex +from .utils import * +from time import time as now +from datetime import datetime + +__author__ = 'Aziz Köksal' +__email__ = 'aziz.koeksal@gmail.com' +__status__ = 'Production' + +class MixFormatProjectCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Runs `mix format` on the project path or the opened folder.' + + def run(self, **_kwargs): + call_mix_format_async(self.window) + +class MixFormatFileCommand(sublime_plugin.TextCommand): + def description(self): + return 'Runs `mix format` on the current file.' + + def run(self, _edit, **kwargs): + window = self.view.window() + file_path = self.view.file_name() + kwargs.get('save', True) and window.run_command('save') + call_mix_format_async(window, file_path=file_path) + + def is_enabled(self): + return is_formattable_syntax(self.view) + +class MixFormatToggleAutoFormatCommand(sublime_plugin.TextCommand): + def description(self): + return 'Enables or disables auto-formatting on save.' + + def run(self, _edit, **_kwargs): + package_settings, mix_format_settings = load_mix_format_settings() + on_save = mix_format_settings['on_save'] = not mix_format_settings.get('on_save', False) + package_settings.set('mix_format', mix_format_settings) + sublime.save_settings(SETTINGS_FILE_NAME) + print_status_msg('%s auto-formatting!' % ['Disabled', 'Enabled'][on_save]) + + def is_enabled(self): + return is_formattable_syntax(self.view) + +class MixFormatOnSaveListener(sublime_plugin.EventListener): + def is_elixir_file(self, view): + return is_formattable_syntax(view) + + def on_post_save(self, view): + if not self.is_elixir_file(view): + return + _, mix_format_settings = load_mix_format_settings() + if mix_format_settings.get('on_save', False): + MixFormatFileCommand(view).run(None, save=False) + if mix_format_settings.get('reload_after', False): + view.run_command('revert') + +def load_mix_format_settings(): + package_settings = sublime.load_settings(SETTINGS_FILE_NAME) + return (package_settings, package_settings.get('mix_format', {})) + +def call_mix_format_async(window, **kwargs): + file_path = kwargs.get('file_path') + print_status_msg('Formatting %s!' % (file_path and repr(file_path) or 'project')) + sublime.set_timeout_async(lambda: call_mix_format(window, **kwargs)) + +def call_mix_format(window, **kwargs): + file_path = kwargs.get('file_path') + file_path_list = file_path and [file_path] or [] + _, cmd_setting = load_mix_format_settings() + cmd_args = (cmd_setting.get('cmd') or ['mix', 'format']) + file_path_list + + paths = file_path_list + window.folders() + cwd = next((reverse_find_root_folder(p) for p in paths if p), None) + + if not (cwd or file_path): + print_status_msg(COULDNT_FIND_MIX_EXS) + return + + proc = subprocess.Popen(cmd_args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0) + + panel_name = 'mix_format' + panel_params = {'panel': 'output.%s' % panel_name} + window.run_command('erase_view', panel_params) + output_view = None + failed_msg_region = None + + try: + for text in read_proc_text_output(proc): + if not output_view: + # Only open the panel when mix is compiling or there is an error. + output_view = create_mix_format_panel(window, panel_name, cmd_args, cwd) + window.run_command('show_panel', panel_params) + + output_view.run_command('append', {'characters': text, 'disable_tab_translation': True}) + except BaseException as e: + write_output(PRINT_PREFIX + " Exception: %s" % repr(e)) + + if output_view: + output_view.set_read_only(True) + failed_msg_region = output_view.find("mix format failed", 0, sublime.IGNORECASE) + failed_msg_region and output_view.show_at_center(failed_msg_region) + + # Either there was no output or there was but without an error. + if not output_view or not failed_msg_region: + if window.active_panel() == panel_params['panel']: + window.run_command('hide_panel', panel_params) + window.destroy_output_panel(panel_name) + + msg = 'Formatted %s %s!' % (file_path and 'file' or 'directory', repr(file_path or cwd)) + print_status_msg(msg) + +def create_mix_format_panel(window, panel_name, cmd_args, cwd): + first_lines = '$ cd %s && %s' % (shlex.quote(cwd), ' '.join(map(shlex.quote, cmd_args))) + first_lines += '\n# Timestamp: %s\n\n' % datetime.now().replace(microsecond=0) + + output_view = window.create_output_panel(panel_name) + output_view.settings().set('result_file_regex', MIX_RESULT_FILE_REGEX) + output_view.settings().set('result_base_dir', cwd) + output_view.set_read_only(False) + output_view.run_command('append', {'characters': first_lines}) + output_view.run_command('move_to', {'to': 'eof'}) + + return output_view diff --git a/commands/mix_test.py b/commands/mix_test.py new file mode 100644 index 00000000..c694491a --- /dev/null +++ b/commands/mix_test.py @@ -0,0 +1,679 @@ +import sublime +import sublime_plugin +import subprocess +import shlex +import re +from os import path, fstat +from pathlib import Path +from time import time as now +from datetime import datetime +from .utils import * + +__author__ = 'Aziz Köksal' +__email__ = 'aziz.koeksal@gmail.com' +__status__ = 'Production' + +# TODO: create a plugin test-suite: https://github.com/SublimeText/UnitTesting/ + +PANEL_NAME = 'output.mix_test' + +class FILE_NAMES: + SETTINGS_JSON = 'mix_test.settings.json' + REPEAT_JSON = 'mix_test.repeat.json' + +class MixTestSettingsCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Opens the `mix test` settings file for the current project.' + + def run(self, **_kwargs): + abs_file_path = self.window.active_view().file_name() + mix_settings_path = reverse_find_json_path(self.window, FILE_NAMES.SETTINGS_JSON) + + if mix_settings_path: + if not path.exists(mix_settings_path): + save_json_file(mix_settings_path, add_help_info({'args': []})) + sublime_NewFileFlags_NONE = 4 + self.window.open_file(mix_settings_path, flags=sublime_NewFileFlags_NONE) + else: + sublime.message_dialog(COULDNT_FIND_MIX_EXS) + +class MixTestCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Runs the full test-suite with `mix test`.' + + def run(self, **_kwargs): + call_mix_test_with_settings(self.window) + +class MixTestFileCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Runs `mix test` on the current test file.' + + def run(self, **_kwargs): + abs_file_path = self.window.active_view().file_name() + assert_is_test_file(abs_file_path) + call_mix_test_with_settings(self.window, abs_file_path=abs_file_path) + + def is_enabled(self): + return is_test_file(self.window.active_view().file_name()) + +class MixTestSelectionCommand(sublime_plugin.TextCommand): + def description(self): + return 'Runs `mix test` with the currently selected lines.' + + def run(self, _edit): + abs_file_path = self.view.file_name() + assert_is_test_file(abs_file_path) + + found_test_header_regions = \ + self.view.find_all(r'(?x) ^ [^\S\n]* (describe|test) [^\S\n]* (?=[\'"] | ~[a-zA-Z])') + + found_test_header_regions = ( + r + for r in found_test_header_regions + if self.view.match_selector( + r.a + len([c for c in self.view.substr(r).rstrip() if c.isspace()]), + 'variable.function.elixir' + ) + ) + + selector_lookup_table = {} + + all_test_block_regions = ( + get_test_block_regions(self.view, header_region, selector_lookup_table) + for header_region in found_test_header_regions + ) + + all_test_block_regions = [r for r in all_test_block_regions if r] + + intersecting_test_regions = [ + regions + for selected_lines_region in map(self.view.line, self.view.sel()) + for regions in all_test_block_regions + if regions[-1].intersects(selected_lines_region) + ] + + unique_intersecting_test_tuples = \ + unique_items([tuple(r.to_tuple() for r in regions) for regions in intersecting_test_regions]) + + intersecting_test_regions = \ + [tuple(sublime.Region(*t) for t in tuples) for tuples in unique_intersecting_test_tuples] + + grouped_by_describe_dict, _ = group_by_describe_block_regions(all_test_block_regions) + + grouped_isecting_by_describe_dict, test_to_describe_dict = \ + group_by_describe_block_regions(intersecting_test_regions) + + exclude_tests_set = set() + + def contains_all_tests(describe_region): + """ Returns `True` when all tests of a describe-block were selected. """ + all_tests = grouped_by_describe_dict[describe_region.to_tuple()] + contains_all = all_tests == grouped_isecting_by_describe_dict[describe_region.to_tuple()] + contains_all = contains_all and len(all_tests) > 1 + contains_all and exclude_tests_set.update(t.to_tuple() for t in all_tests) + return contains_all + + # Filter out or keep describe-block regions or their contained tests + # depending on whether all tests were selected or not. + selected_test_regions = [ + ((header_region, name_region), test_to_describe_dict.get(whole_region.to_tuple())) + for header_region, name_region, _block_region, whole_region in intersecting_test_regions + if whole_region.to_tuple() not in exclude_tests_set and ( + whole_region.to_tuple() not in grouped_isecting_by_describe_dict + or contains_all_tests(whole_region) + ) + ] + + selected_tests = [ + encode_json_test_name( + *[self.view.substr(r).strip() for r in header_and_name_regions] + + [self.view.substr(describe_tuple and describe_tuple[1] or sublime.Region(0))] + ) + for header_and_name_regions, describe_tuple in selected_test_regions + ] + + # Use the selected lines if no tests were found. + if selected_tests: + params = {'names': selected_tests} + else: + params = {'lines': list(self.view.rowcol(min(sel.a, sel.b))[0] + 1 for sel in self.view.sel())} + + params.setdefault('abs_file_path', abs_file_path) + + call_mix_test_with_settings(self.view.window(), **params) + + # This function is unused but kept to have a fallback in case + # the more complicated algorithm doesn't work well enough in the wild. + def get_selected_tests_by_closest_line_nrs(self, found_test_headers, test_header_line_nrs): + """ Simpler algorithm which considers only line numbers and no do-end blocks. """ + selected_line_nrs = [self.view.rowcol(region.a)[0] + 1 for region in self.view.sel()] + test_header_line_nrs = [self.view.rowcol(region.a)[0] + 1 for region in found_test_headers] + reversed_test_header_line_nrs = list(reversed(list(enumerate(test_header_line_nrs)))) + + indexes = ( + next( + (i for (i, header_line) in reversed_test_header_line_nrs if line_nr >= header_line), + None + ) + for line_nr in selected_line_nrs + ) + + test_names = [extract_test_name(self.view, r.b) for r in found_test_headers] + return [test_names[idx] for idx in sorted(list(set(indexes))) if idx != None] + + def is_enabled(self): + return is_test_file(self.view.file_name()) + +class MixTestFailedCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Repeats only tests that failed the last time.' + + def run(self, **_kwargs): + call_mix_test_with_settings(self.window, failed=True) + +class MixTestRepeatCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Repeats `mix test` with the last used parameters.' + + def run(self, **_kwargs): + json_path = reverse_find_json_path(self.window, path.join('_build', FILE_NAMES.REPEAT_JSON)) + + if json_path: + call_mix_test_with_settings(self.window, **load_json_file(json_path)) + else: + print_status_msg('Error: No tests to repeat.') + +class MixTestSetSeedCommand(sublime_plugin.TextCommand): + def description(self): + return 'Sets the seed value with which `mix test` is run.' + + def run(self, _edit, seed=None): + mix_settings_path = reverse_find_json_path(self.view.window(), FILE_NAMES.SETTINGS_JSON) + if not mix_settings_path: + return + + mix_params = load_json_file(mix_settings_path) + seed = self.view.substr(self.view.sel()[0]) if seed is None else seed + seed = seed.strip() if type(seed) == str else seed + msg = None + + if type(seed) == int or seed == '' or type(seed) == str and seed.isdecimal(): + if seed != '': + msg = 'Mix test seed set to: %d' % int(seed) + mix_params['seed'] = int(seed) + else: + msg = 'Erased mix test seed.' + 'seed' in mix_params and mix_params.pop('seed') + + save_json_file(mix_settings_path, add_help_info(mix_params)) + + print_status_msg(msg or 'Error: cannot set mix test seed to: %r' % seed) + + def input(self, _args): + class SeedInputHandler(sublime_plugin.TextInputHandler): + def placeholder(self): return 'Enter a number or leave empty to erase.' + def validate(self, text): return text.strip().isdecimal() or text == '' + + is_decimal = self.view.substr(self.view.sel()[0]).strip().isdecimal() + return SeedInputHandler() if not is_decimal else None + +class MixTestToggleStaleFlagCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Toggles the --stale flag.' + + def run(self, **_kwargs): + mix_settings_path = reverse_find_json_path(self.window, FILE_NAMES.SETTINGS_JSON) + if not mix_settings_path: + return + mix_params = load_json_file(mix_settings_path) + args = mix_params.get('args', []) + has_stale_flag = '--stale' in args + args = [a for a in args if a != '--stale'] if has_stale_flag else args + ['--stale'] + mix_params['args'] = args + save_json_file(mix_settings_path, mix_params) + print_status_msg('%s mix test --stale flag!' % ['Added', 'Removed'][has_stale_flag]) + +class MixTestSwitchToCodeOrTestCommand(sublime_plugin.TextCommand): + def description(self): + return 'Finds the corresponding source file of the test and vice versa if possible.' + + def run(self, _edit): + window = self.view.window() + file_path = Path(self.view.file_name()) + parts = file_path.name.rsplit('_test.exs', 1) + is_test = parts[1:] == [''] + search_names = \ + [parts[0] + ext for ext in ('.ex', '.exs')] if is_test else [file_path.stem + '_test.exs'] + ignored_folders = ['.elixir_ls', '_build', 'deps'] + + subpaths = [ + p + for folder in (window.folders() or [reverse_find_root_folder(file_path)]) if folder + for p in Path(folder).iterdir() + if p.is_file() or p.name not in ignored_folders + ] + + counterpart_paths = [ + (subpath, p) + for subpath in subpaths + for p in (subpath.rglob("*.ex*") if subpath.is_dir() else [subpath]) + if p.name in search_names + ] + + if len(counterpart_paths) > 1: + on_select = lambda i: i >= 0 and window.open_file(str(counterpart_paths[i][1])) + + file_path_items = [ + sublime.QuickPanelItem( + trigger=str(path.relative_to(folder)), + details='Folder: %s' % folder, + kind=sublime.KIND_NAVIGATION + ) + for folder, path in counterpart_paths + ] + + window.show_quick_panel(file_path_items, on_select) + elif counterpart_paths: + window.open_file(str(counterpart_paths[0][1])) + else: + test_or_code = ['test', 'code'][is_test] + print_status_msg('Error: could not find the counterpart %s file.' % test_or_code) + + def is_enabled(self): + return is_elixir_syntax(self.view) + +class MixTestShowPanelCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Shows the output panel if existent and hidden.' + + def run(self, **_kwargs): + self.window.run_command('show_panel', {'panel': PANEL_NAME}) + + def is_enabled(self): + return PANEL_NAME != self.window.active_panel() and PANEL_NAME in self.window.panels() + +class MixTestHidePanelCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Hides the output panel if visible.' + + def run(self, **_kwargs): + self.window.run_command('hide_panel', {'panel': PANEL_NAME}) + + def is_enabled(self): + return PANEL_NAME == self.window.active_panel() + + +# Helper functions: +## + +def is_test_file(file_path): return (file_path or '').endswith('_test.exs') + +def assert_is_test_file(file_path): + if not is_test_file(file_path): + msg = 'Not an Elixir test file! Its name must end with "*_test.exs"!' + print_status_msg(msg) + raise Exception(msg) + +def extract_test_name(view, test_name_begin_point): + return view.substr(expand_scope_right(view, test_name_begin_point, 'meta.string.elixir')) + +def get_test_block_regions(view, header_region, lookup_table): + """ Returns the test's header, name, do-block and complete region, or `None` otherwise. """ + name_region = expand_scope_right(view, header_region.b, 'meta.string.elixir') + point, view_size = name_region.b, view.size() + begin_scopes_counter = 0 + + # TODO: use view.expand_to_scope() when available? + + while point < view_size: + token_region = view.extract_scope(point) + token_str = view.substr(token_region) + scope_names = view.scope_name(point) + + if begin_scopes_counter == 0 and token_str in ('do', 'do:'): + meta_block_elixir_scopes = ['meta.block.elixir'] * scope_names.count('meta.block.elixir') + + meta_scopes = ' '.join(meta_block_elixir_scopes + ( + [] if token_str == 'do' else ['meta.function-call.arguments.elixir'] + )) + + block_regions = lookup_table.setdefault( + meta_scopes, + meta_scopes in lookup_table or view.find_by_selector(meta_scopes) + ) + + do_block_region = next((r for r in block_regions if r.contains(point)), None) + + if do_block_region: + complete_region = sublime.Region(header_region.a, do_block_region.b) + return (header_region, name_region, do_block_region, complete_region) + + # Keep track of opening and closing tokens in order to skip unwanted do-blocks: + is_begin_scope = '.begin.elixir' in scope_names + + if token_str not in ': % # ' and (is_begin_scope or '.end.elixir' in scope_names): + point += len(token_str) - 1 + begin_scopes_counter += 1 if is_begin_scope else -1 + + point += 1 + +def group_by_describe_block_regions(test_block_regions): + """ + Returns a dict mapping a describe-block to its tests + and a dict mapping a test to its describe-block. + """ + grouped_by_describe_dict = {} + test_to_describe_dict = {} + parent_describe = None + group_stack = [] + + def maybe_put_group_stack(): + nonlocal group_stack + if group_stack: + grouped_by_describe_dict[parent_describe[-1].to_tuple()] = group_stack + group_stack = [] + + for regions in test_block_regions: + whole_region = regions[-1] + + if parent_describe and parent_describe[-1].contains(whole_region): + test_to_describe_dict[whole_region.to_tuple()] = parent_describe + group_stack.append(whole_region) + else: + maybe_put_group_stack() + parent_describe = regions + + maybe_put_group_stack() + + return (grouped_by_describe_dict, test_to_describe_dict) + +def encode_json_test_name(type, name, describe_name): + parent_describe = '%s %s\0' % ('describe', describe_name) if describe_name else '' + return parent_describe + '%s %s' % (type, name) + +def decode_json_test_name(type_names): + parts = [type_name.split(' ', 1) for type_name in type_names.split('\0', 1)] + return parts if len(parts) == 2 else [[None, None]] + parts + +def find_lines_using_test_names(file_path, test_names): + """ Scans a text file and returns located as well as unlocated tests. """ + original_file_text, pruned_text = '', '' + with open(file_path, 'r') as file: original_file_text = file.read() + + located_tests, unlocated_tests = [], [] + + # This regex relies on the code being indented/formatted properly. + describe_block_rx = re.compile( + r'(?x) ^ ([^\S\n]*) describe [^\S\n]* (?:[\'"] | ~[a-zA-Z]) [\s\S]+?' + + r'\bdo\b (?![?!:]) [\s\S]*? \n \1 \bend\b (?![?!:])', + re.MULTILINE + ) + + for [[has_parent, parent_describe_name], [type, name]] \ + in map(decode_json_test_name, test_names): + modified_file_text = original_file_text + previous_newlines = 0 + + # Different describe-blocks may contain tests with the same name. + # Need to first skip to the parent describe-block and search from there. + if has_parent: + rx = r'(?x) ^ [^\S\n]* %s [^\S\n]* %s' % ('describe', re.escape(parent_describe_name)) + match = re.search(rx, original_file_text, re.MULTILINE) + + if match: + modified_file_text = original_file_text[match.start() : ] + previous_newlines = original_file_text[0 : match.start()].count('\n') + else: + unlocated_tests.append((parent_describe_name, type, name)) + continue + elif type == 'test': + # Avoid possibly matching a test with the same name inside a do-block. + if not pruned_text: + replacer = lambda s: '\n' * s.group().count('\n') + pruned_text = re.sub(describe_block_rx, replacer, original_file_text) + + modified_file_text = pruned_text + + rx = r'(?x) ^ [^\S\n]* %s [^\S\n]* %s' % (type, re.escape(name)) + match = re.search(rx, modified_file_text, re.MULTILINE) + + if match: + line_number = modified_file_text[0 : match.start()].count('\n') + previous_newlines + 1 + located_tests.append((type, name, line_number)) + else: + unlocated_tests.append((parent_describe_name, type, name)) + + return (located_tests, unlocated_tests) + +def reverse_find_json_path(window, json_file_path): + """ Tries to find the given JSON file by going up the folder tree + and trying different starting locations. """ + paths = [window.active_view().file_name()] + window.folders() + root_dir = next((reverse_find_root_folder(p) for p in paths if p), None) + + if not root_dir: + sublime.message_dialog(COULDNT_FIND_MIX_EXS) + print_status_msg(COULDNT_FIND_MIX_EXS) + + return root_dir and path.join(root_dir, json_file_path) or None + +def call_mix_test_with_settings(window, **params): + """ Calls `mix test` with the settings JSON merged with the given params. """ + try_run_mix_test(window, params) + +def merge_mix_settings_and_params(window, params): + """ Merges the settings JSON with the given params. """ + mix_settings_path = reverse_find_json_path(window, FILE_NAMES.SETTINGS_JSON) + + if not mix_settings_path: + return + + root_dir = path.dirname(mix_settings_path) + + if 'abs_file_path' in params: + params.setdefault('file_path', path.relpath(params['abs_file_path'], root_dir)) + del params['abs_file_path'] + + build_dir = Path(root_dir) / '_build' + build_dir.exists() or build_dir.mkdir() + save_json_file(str(build_dir / FILE_NAMES.REPEAT_JSON), params) + + mix_params = load_json_file(mix_settings_path) + mix_params = remove_help_info(mix_params) + mix_params.update(params) + mix_params.setdefault('cwd', root_dir) + + return (mix_params, root_dir) + +def get_mix_test_arguments(window, mix_params, cwd): + """ Calls `mix test` in an asynchronous thread. """ + cmd, file_path, names, lines, seed, failed, args = \ + list(map(mix_params.get, ('cmd', 'file_path', 'names', 'lines', 'seed', 'failed', 'args'))) + + located_tests, unlocated_tests = \ + names and find_lines_using_test_names(path.join(cwd, file_path), names) or (None, None) + + if unlocated_tests: + mix_params['unlocated_tests'] = unlocated_tests + + if file_path and located_tests: + file_path += ''.join(':%s' % l for (_t, _n, l) in located_tests) + + if file_path and lines: + file_path += ''.join(':%s' % l for l in lines) + + mix_test_pckg_settings = sublime.load_settings(SETTINGS_FILE_NAME).get('mix_test', {}) + + def get_setting(key): + return next((s.get(key) for s in [mix_params, mix_test_pckg_settings] if key in s), None) + + cmd = get_setting('cmd') + args = get_setting('args') + seed = get_setting('seed') + + seed_arg = ['--seed', str(seed)] if seed is not None else [] + file_path_arg = file_path and [file_path] or [] + cmd_arg = cmd or ['mix', 'test'] + failed_arg = failed and ['--failed'] or [] + mix_command = cmd_arg + seed_arg + file_path_arg + (args or []) + failed_arg + + return (cmd_arg, mix_command, get_setting) + +IS_MIX_TEST_RUNNING = False + +def try_run_mix_test_async(window, params): + global IS_MIX_TEST_RUNNING + + try: + IS_MIX_TEST_RUNNING = True + (mix_params, cwd) = merge_mix_settings_and_params(window, params) + (cmd_arg, mix_command, get_setting) = get_mix_test_arguments(window, mix_params, cwd) + print('%s `%s` parameters: %s' % (PRINT_PREFIX, ' '.join(cmd_arg), repr(mix_params))) + run_mix_test(window, mix_command, mix_params, cwd, get_setting) + finally: + IS_MIX_TEST_RUNNING = False + +def try_run_mix_test(window, params): + if IS_MIX_TEST_RUNNING: + # NB: showing a blocking dialog here stops the reading of the subprocess output somehow. + sublime.set_timeout_async(lambda: sublime.message_dialog('The `mix test` process is still running!')) + print_status_msg('mix test is already running!') + return + + sublime.set_timeout_async(lambda: try_run_mix_test_async(window, params)) + +def run_mix_test(window, cmd_args, params, cwd, get_setting): + """ Creates the output view/file and runs the `mix test` process. """ + mix_test_output = get_setting('output') or 'panel' + output_view = output_file = None + + if type(mix_test_output) != str: + msg = 'Error: "output" setting is not of type string, but: %r' + print_status_msg(msg % type(mix_test_output)) + elif mix_test_output == 'tab': + output_view = window.new_file() + output_view.set_scratch(True) + elif mix_test_output == 'panel': + output_view = window.create_output_panel('mix_test') + window.run_command('show_panel', {'panel': PANEL_NAME}) + elif mix_test_output.startswith('file://'): + mode = get_setting('output_mode') or 'w' + output_path = Path(mix_test_output[len('file://'):]) + output_path = str( + output_path.is_absolute() and output_path + or (window.folders() + [cwd])[0] / output_path + ) + + try: + output_file = open(output_path, mode) + except (PermissionError, FileNotFoundError, IsADirectoryError) as e: + msg = 'Error: could not open output file %r with mode %r (%s)' + print_status_msg(msg % (output_path, mode, e)) + + if not (output_view or output_file): + msg = 'Error: cannot run `mix test`. No valid output setting ("output": %r).' + print_status_msg(msg % mix_test_output) + return + + if output_view: + active_view_settings = window.active_view().settings() + # output_view.assign_syntax('Packages/X/Y.sublime-syntax') + file_path = params.get('file_path') + output_view.retarget('%s.log' % (file_path and path.join(cwd, file_path))) + output_view.set_name('mix test' + (file_path and ' ' + file_path or '')) + ov_settings = output_view.settings() + ov_settings.set('word_wrap', active_view_settings.get('word_wrap')) + ov_settings.set('result_file_regex', MIX_RESULT_FILE_REGEX) + ov_settings.set('result_base_dir', cwd) + output_view.set_read_only(False) + + def write_output(txt): + if output_file: + output_file.write(txt) + output_file.flush() + else: + output_view.run_command('append', {'characters': txt, 'disable_tab_translation': True}) + + if params.get('unlocated_tests'): + write_output( + 'Error: could not find previously selected tests:\n%s\n\n' % '\n'.join( + ' %d. %s%s %s' % (i + 1, d and 'describe %s -> ' % d or '', t, name.replace('\n', '\\n')) + for i, (d, t, name) in enumerate(params.get('unlocated_tests')) + ) + + 'File: %s\n\n' % path.join(cwd, params.get('file_path')) + + 'This error occurs when:\n' + + '* a test\'s name has been changed, or\n' + + '* the test file has unsaved changes.\n\n' + + 'Reselect the tests to be run or edit _build/%s to fix the name(s).\n' + % FILE_NAMES.REPEAT_JSON + ) + return + + proc = subprocess.Popen(cmd_args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0) + + if output_view: + output_view.settings().set('view_id', output_view.id()) + + cmd = ' '.join(params.get('cmd') or ['mix test']) + cmd_string = ' '.join(map(shlex.quote, cmd_args)) + first_lines = '$ cd %s && %s' % (shlex.quote(cwd), cmd_string) + first_lines += '\n# `%s` pid: %s' % (cmd, proc.pid) + first_lines += '\n# Timestamp: %s' % datetime.now().replace(microsecond=0) + if params.get('names'): + first_lines += '\n# Selected tests:\n%s' % '\n'.join( + '# %d. %s' % (i + 1, n.replace('\0', ' -> ').replace('\n', '\\n')) + for i, n in enumerate(params.get('names')) + ) + + print(PRINT_PREFIX + ''.join('\n' + (line and ' ' + line) for line in first_lines.split('\n'))) + write_output(first_lines + '\n\n') + output_view and output_view.run_command('move_to', {'to': 'eof'}) + + continue_hidden = False + + try: + for text in read_proc_text_output(proc): + if not continue_hidden \ + and (output_file and fstat(output_file.fileno()).st_nlink == 0 \ + or output_view and not output_view.window()): + continue_hidden = continue_on_output_close(proc, cmd) + if not continue_hidden: + break + + write_output(text) + except BaseException as e: + write_output(PRINT_PREFIX + "Exception: %s" % repr(e)) + + if output_file: + output_file.close() + else: + output_view.set_read_only(True) + + print_status_msg('Finished `%s`!' % cmd_string) + +def continue_on_output_close(proc, cmd): + can_continue = sublime.ok_cancel_dialog( + 'The `%s` process is still running. Continue in the background?' % cmd, + ok_title='Yes', title='Continue running `%s`' % cmd + ) + + if not can_continue: + print_status_msg('Stopping `%s` (pid=%s).' % (cmd, proc.pid)) + proc.send_signal(subprocess.signal.SIGQUIT) + + return can_continue + +def add_help_info(dict_data): + dict_data['help'] = { + '': 'To configure a setting add the key to the root JSON object.', + 'output': {'description': 'Choose where to display the command\'s output.', 'default': 'panel', 'values': ['tab', 'panel', 'file://...']}, + 'output_mode': {'description': 'Output mode of the disk file to open/create.', 'default': 'w', 'values': 'see `open()` modifiers'}, + 'cmd': {'description': 'Which command to execute.', 'default': ['mix', 'test']}, + 'args': {'description': 'Additional arguments to pass to `cmd`.', 'default': [], 'values': 'see `mix help test`'}, + 'seed': {'description': 'The seed with which to randomize the tests.', 'default': None, 'values': [None, 'non-negative integer']}, + } + return dict_data + +def remove_help_info(dict_data): + 'help' in dict_data and dict_data.pop('help') + return dict_data diff --git a/commands/utils.py b/commands/utils.py new file mode 100644 index 00000000..c9075123 --- /dev/null +++ b/commands/utils.py @@ -0,0 +1,102 @@ +import sublime +import json +from pathlib import Path + +__author__ = 'Aziz Köksal' +__email__ = 'aziz.koeksal@gmail.com' +__status__ = 'Production' + +SETTINGS_FILE_NAME = 'ElixirSyntax.sublime-settings' + +PRINT_PREFIX = 'ElixirSyntax:' + +# The regex is used by Sublime to find and jump to error locations shown in output panels. +MIX_RESULT_FILE_REGEX = r'(\S+?[/\\]\S+?\.[a-zA-Z]+):(\d+)(?::(\d+))?' + +COULDNT_FIND_MIX_EXS = \ + 'Error: could not find a mix.exs file!\n' + \ + 'Make sure that you are in a mix project.' + +def print_status_msg(msg): + print(PRINT_PREFIX, msg) + sublime.status_message(PRINT_PREFIX + ' ' + msg) + +def unique_items(items): + unique_items, seen_items = [], set() + + for item in items: + if item not in seen_items: + unique_items.append(item) + seen_items.add(item) + + return unique_items + +def expand_scope_right(view, begin_point, scope): + end_point = next( + (pt for pt in range(begin_point, view.size()) if not view.match_selector(pt, scope)), + begin_point + ) + return sublime.Region(begin_point, end_point) + +def has_one_of_scope_suffixes(view, scope_suffixes): + view_scope_suffixes = view.scope_name(0).split(' ')[0].split('.')[1:] + return any(suffix in view_scope_suffixes for suffix in scope_suffixes) + +def is_elixir_syntax(view): + return has_one_of_scope_suffixes(view, ['elixir']) + +def is_formattable_syntax(view): + return has_one_of_scope_suffixes(view, ['elixir', 'eex', 'heex', 'surface']) + +def reverse_find_root_folder(bottom_path): + bottom_path = Path(bottom_path) + parent_path = bottom_path.parent if bottom_path.is_file() else bottom_path + + while True: + # We have to check for the root mix.exs, ignoring possible sub-app mix files. + if (parent_path / 'mix.exs').exists() \ + and ( + (parent_path / 'mix.lock').exists() + or (parent_path / '_build').exists() + or parent_path.name != 'apps' and not (parent_path.parent / 'mix.exs').exists() + ): + return str(parent_path) + + old_path, parent_path = parent_path, parent_path.parent + + if old_path == parent_path: + break + + return None + +def read_proc_text_output(proc, size=1024): + while proc.poll() is None: + # TODO: the subprocess should be opened with an encoding to avoid the decode call, + # but the option is not supported in Sublime's Python yet. + text = proc.stdout.read(size).decode(encoding='UTF-8') + if not text: continue + yield text + return '' + +def save_json_file(file_path, dict_data): + try: + with open(str(file_path), 'w') as file: + try: + return json.dump(dict_data, file, indent=2) + except BaseException as e: + print_status_msg('Error: could not save JSON to: %r\nException: %s' % (file_path, e)) + except BaseException as e: + print_status_msg('Error: could not open file: %r\nException: %s' % (file_path, e)) + +def load_json_file(file_path): + try: + with open(str(file_path), 'r') as file: + try: + return json.load(file) + except BaseException as e: + print_status_msg('Error: could not load JSON from: %r\nException: %s' % (file_path, e)) + except BaseException as e: + exists = Path(file_path).exists() + exists and print_status_msg('Error: could not open file: %r\nException: %s' % (file_path, e)) + + return {} diff --git a/completions/Phoenix_Attributes.sublime-completions b/completions/Phoenix_Attributes.sublime-completions new file mode 100644 index 00000000..580b36a3 --- /dev/null +++ b/completions/Phoenix_Attributes.sublime-completions @@ -0,0 +1,149 @@ +{ + "scope": "text.html.heex meta.attribute-with-value", + "completions": [ + { + "trigger": "phx-value-*", + "contents": "phx-value-${1:*}=\"$2\"", + "kind": "snippet", + "details": "Params" + }, + { + "trigger": "phx-click", + "contents": "phx-click=\"$1\"", + "kind": "snippet", + "details": "Click Events" + }, + { + "trigger": "phx-click-away", + "contents": "phx-click-away=\"$1\"", + "kind": "snippet", + "details": "Click Events" + }, + { + "trigger": "phx-change", + "contents": "phx-change=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-submit", + "contents": "phx-submit=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-feedback-for", + "contents": "phx-feedback-for=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-disable-with", + "contents": "phx-disable-with=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-trigger-action", + "contents": "phx-trigger-action=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-auto-recover", + "contents": "phx-auto-recover=\"$1\"", + "kind": "snippet", + "details": "Form Events" + }, + { + "trigger": "phx-blur", + "contents": "phx-blur=\"$1\"", + "kind": "snippet", + "details": "Focus Events" + }, + { + "trigger": "phx-focus", + "contents": "phx-focus=\"$1\"", + "kind": "snippet", + "details": "Focus Events" + }, + { + "trigger": "phx-window-blur", + "contents": "phx-window-blur=\"$1\"", + "kind": "snippet", + "details": "Focus Events" + }, + { + "trigger": "phx-window-focus", + "contents": "phx-window-focus=\"$1\"", + "kind": "snippet", + "details": "Focus Events" + }, + { + "trigger": "phx-keydown", + "contents": "phx-keydown=\"$1\"", + "kind": "snippet", + "details": "Key Events" + }, + { + "trigger": "phx-keyup", + "contents": "phx-keyup=\"$1\"", + "kind": "snippet", + "details": "Key Events" + }, + { + "trigger": "phx-window-keydown", + "contents": "phx-window-keydown=\"$1\"", + "kind": "snippet", + "details": "Key Events" + }, + { + "trigger": "phx-window-keyup", + "contents": "phx-window-keyup=\"$1\"", + "kind": "snippet", + "details": "Key Events" + }, + { + "trigger": "phx-key", + "contents": "phx-key=\"$1\"", + "kind": "snippet", + "details": "Key Events" + }, + { + "trigger": "phx-update", + "contents": "phx-update=\"$1\"", + "kind": "snippet", + "details": "DOM Patching" + }, + { + "trigger": "phx-remove", + "contents": "phx-remove=\"$1\"", + "kind": "snippet", + "details": "DOM Patching" + }, + { + "trigger": "phx-hook", + "contents": "phx-hook=\"$1\"", + "kind": "snippet", + "details": "JS Interop" + }, + { + "trigger": "phx-debounce", + "contents": "phx-debounce=\"$1\"", + "kind": "snippet", + "details": "Rate Limiting" + }, + { + "trigger": "phx-throttle", + "contents": "phx-throttle=\"$1\"", + "kind": "snippet", + "details": "Rate Limiting" + }, + { + "trigger": "phx-track-static", + "contents": "phx-track-static=\"$1\"", + "kind": "snippet", + "details": "Static Tracking" + } + ] +} diff --git a/completions/Surface_Attributes.sublime-completions b/completions/Surface_Attributes.sublime-completions new file mode 100644 index 00000000..573692b1 --- /dev/null +++ b/completions/Surface_Attributes.sublime-completions @@ -0,0 +1,113 @@ +{ + "scope": "text.html.surface meta.attribute-with-value", + "completions": [ + { + "trigger": "if", + "contents": ":if={$1}", + "kind": "markup", + "details": ":if" + }, + { + "trigger": "hook", + "contents": ":hook={$1}", + "kind": "markup", + "details": ":hook" + }, + { + "trigger": "show", + "contents": ":show={$1}", + "kind": "markup", + "details": ":show" + }, + { + "trigger": "let", + "contents": ":let={$1}", + "kind": "markup", + "details": ":let" + }, + { + "trigger": "args", + "contents": ":args={$1}", + "kind": "markup", + "details": ":args" + }, + { + "trigger": "values", + "contents": ":values={$1}", + "kind": "markup", + "details": ":values" + }, + { + "trigger": "on-click", + "contents": ":on-click={$1}", + "kind": "markup", + "details": ":on-click" + }, + { + "trigger": "on-capture-click", + "contents": ":on-capture-click={$1}", + "kind": "markup", + "details": ":on-capture-click" + }, + { + "trigger": "on-blur", + "contents": ":on-blur={$1}", + "kind": "markup", + "details": ":on-blur" + }, + { + "trigger": "on-focus", + "contents": ":on-focus={$1}", + "kind": "markup", + "details": ":on-focus" + }, + { + "trigger": "on-change", + "contents": ":on-change={$1}", + "kind": "markup", + "details": ":on-change" + }, + { + "trigger": "on-submit", + "contents": ":on-submit={$1}", + "kind": "markup", + "details": ":on-submit" + }, + { + "trigger": "on-keydown", + "contents": ":on-keydown={$1}", + "kind": "markup", + "details": ":on-keydown" + }, + { + "trigger": "on-keyup", + "contents": ":on-keyup={$1}", + "kind": "markup", + "details": ":on-keyup" + }, + { + "trigger": "on-window-focus", + "contents": ":on-window-focus={$1}", + "kind": "markup", + "details": ":on-window-focus" + }, + { + "trigger": "on-window-blur", + "contents": ":on-window-blur={$1}", + "kind": "markup", + "details": ":on-window-blur" + }, + { + "trigger": "on-window-keydown", + "contents": ":on-window-keydown={$1}", + "kind": "markup", + "details": ":on-window-keydown" + }, + { + "trigger": "on-window-keyup", + "contents": ":on-window-keyup={$1}", + "kind": "markup", + "details": ":on-window-keyup" + } + ] +} diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 00000000..f17c48bd --- /dev/null +++ b/dependencies.json @@ -0,0 +1,7 @@ +{ + "*": { + "*": [ + "pathlib" + ] + } +} diff --git a/images/elixir_fragment_example.svg b/images/elixir_fragment_example.svg index b9957f69..76654145 100644 --- a/images/elixir_fragment_example.svg +++ b/images/elixir_fragment_example.svg @@ -1,4 +1,4 @@ -