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 (`="value"/>`). +- HTML (HEEx): fixed matching function names in tags. +- HTML (HEEx): match phx binding attributes. +- Elixir (EEx): fixed matching comments (`<%# ... %>`). +- SQL: fixed matching decimal numbers. +- SQL: fixed matching quoted member ids (`a_table."a column"`). +- Snippets: added `dbg` keyword for `|> dbg()`. +- Snippets: added EEx tags. +- Snippets: added Elixir `#{...}` string interpolation. +- Snippets: added `require IEx; IEx.pry()` string interpolation. +- Completions: added Phoenix LiveView attribute bindings. +- Completions: added Surface tag attributes. +- Preferences: added increase / decrease indentation settings (thanks to @timfjord). +- Builds: added `elixirc` and `mix compile`. +- Menus: added "ElixirSyntax" to "Preferences > Package Settings". + +## [v2.3.0] – 2021-12-17 + +- Syntaxes: refactored Surface/HEEx/EEx with many improvements (thanks to @deathaxe). +- Themes: slightly darken the embed punctuation markers for Surface and (H)EEx tags. +- Elixir: allow digits in sigil string modifiers. +- Preferences: index Elixir `@attribute` definitions for "Goto Definition". + ## [v2.2.0] – 2021-09-18 - - Syntax: added support for the [HEEx](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#sigil_H/2) template syntax inside the `~H` sigil. - - Syntax: added support for the [Surface](https://surface-ui.org/template_syntax) template syntax inside the `~F` sigil. - - Elixir: match the `**` power operator. - - HTML (EEx): switched to version 2 and removed usage of `with_prototype`. - - SQL: match the `;` token; fixed the `/**/` comment scope. - - Themes: highlight interpolated Elixir with a lighter background. - - Themes: don't italicize the sigil type. + +- Syntax: added support for the [HEEx](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#sigil_H/2) template syntax inside the `~H` sigil. +- Syntax: added support for the [Surface](https://surface-ui.org/template_syntax) template syntax inside the `~F` sigil. +- Elixir: match the `**` power operator. +- HTML (EEx): switched to version 2 and removed usage of `with_prototype`. +- SQL: match the `;` token; fixed the `/**/` comment scope. +- Themes: highlight interpolated Elixir with a lighter background. +- Themes: don't italicize the sigil type. ## [v2.1.0] – 2021-07-25 diff --git a/README.md b/README.md index 6141251e..2250fabb 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,22 @@ # ElixirSyntax -ElixirSyntax was initially based on the [Elixir.tmbundle package](https://github.com/elixir-editors/elixir-tmbundle) but has been rewritten since, providing more accurate syntax matching as well as better syntax highlighting. +*ElixirSyntax* was initially based on the [Elixir.tmbundle package](https://github.com/elixir-editors/elixir-tmbundle) but has been rewritten since, providing more accurate syntax matching as well as better syntax highlighting. ## Features -* Working `Goto Definition` functionality +* Working `Goto Definition` command. * HTML template highlighting: - - HEEx: - - Surface: - - LiveView: -* Full PCRE syntax highlighting: -* Type highlighting: - -Some of the other provided features are not immediately evident. Among them are: + - HEEx: + - Surface: + - LiveView: +* Full PCRE syntax highlighting: +* Type highlighting: +* Theme adaptations for Mariana and Monokai. +* Palette commands: `ElixirSyntax: ...`, `Mix Test: ...`, `Mix Format: ...` +* Build commands: `mix format`, `mix test`, `elixir $file` +* Snippets for `IO.inspect`, `tap` and `then`. + +Some syntax highlighting features are not immediately evident. Among them are: ### The `fragment` and `sql` functions @@ -28,7 +32,7 @@ Add an `sql` macro/function to your project to enjoy SQL highlighting anywhere i -### The JSON `~j` and `~J` sigils ([`Jason`](https://github.com/michalmuskala/jason/blob/ec2042e4e47442bf3d58410934b8e8f6ff850b3f/lib/sigil.ex)) +### The JSON `~j` and `~J` sigils ([`Jason`](https://github.com/michalmuskala/jason/blob/master/lib/sigil.ex)) Embed JSON strings in your Elixir code. Notice the interpolated Elixir code is colored correctly. @@ -42,10 +46,87 @@ Embed JSON strings in your Elixir code. Notice the interpolated Elixir code is c +## Testing + +Build-files as well as commands are provided for calling `mix test`. The predefined shortcuts can be changed via `Preferences > Package Settings > ElixirSyntax > Key Bindings`. + +Tip: To run specific tests in the current file, mark them with multiple cursors and/or spanning selections and press `Alt+Shift+T` or choose `Mix Test: Selection(s)` from the palette. + +*ElixirSyntax* stores a per-project JSON settings file in the root folder that contains both the `mix.exs` file and the `_build/` folder. They override the general settings below. + +General settings example (via `Preferences > Package Settings > ElixirSyntax > Settings`): +```json +{ + "mix_test": { + "output": "tab", + "output_mode": null, + "args": ["--coverage"], + "seed": null + } +} +``` + +When a `mix test` command is run the first time, a `mix_test.repeat.json` file is stored in the `_build/` folder to remember the command arguments. By pressing `Alt+Shift+R` or running `Mix Test: Repeat` from the palette you can repeat the previously executed tests. + +## Formatting + +Use the default shortcut `Alt+Shift+F` or the palette command `Mix Format: File` to format your Elixir code. Format the whole project via `Mix Format: Project / Folder`. Configure auto-formatting on save via the palette command `Mix Format: Toggle Auto-Formatting` or via the menu `Preferences > Package Settings > ElixirSyntax > Settings`. There is no per-project auto-format setting yet. + +```json +{ + "mix_format": { + "on_save": true + } +} +``` + +## Palette commands + +- `ElixirSyntax: Settings` +- `ElixirSyntax: Open Hex Docs` +- `ElixirSyntax: Search Hex Packages` +- `Mix Test: Settings` +- `Mix Test: All` +- `Mix Test: File` +- `Mix Test: Selection(s)` +- `Mix Test: Failed` +- `Mix Test: Repeat` +- `Mix Test: Set --seed` +- `Mix Test: Toggle --stale Flag` +- `Mix Test: Switch to Code or Test` +- `Mix Test: Show Panel` +- `Mix Format: File` +- `Mix Format: Project / Folder` +- `Mix Format: Toggle Auto-Formatting` + +## Recommended packages + +* [LSP](https://packagecontrol.io/packages/LSP) and [LSP-elixir](https://packagecontrol.io/packages/LSP-elixir) for intelligent code completion and additional snippet suggestions. + ## Changes See [CHANGELOG.md](./CHANGELOG.md) for the list of releases and noteworthy changes. +## FAQ + +- How to color unused variables, e.g. `_opts`, differently? + + You can [customize the color](https://user-images.githubusercontent.com/1329716/152258038-384c6a61-d974-4e9a-a1db-ab979c839ff7.png) of unused variable names by extending your color scheme, targeting the `variable.parameter.unused` and `variable.other.unused` scopes: + + ```json + { + "rules": [ + { + "name": "Unused variables", + "scope": "variable.parameter.unused, variable.other.unused", + "foreground": "#8c8cff" + } + ] + } + ``` + + More details at [Sublime Text Docs](https://www.sublimetext.com/docs/color_schemes.html) + ## Contributors/Maintainers - [@azizk](https://github.com/azizk) rewrote the whole syntax definition with an extensive test-suite and added a wealth of new features. ⭐ diff --git a/builds/Elixir - elixirc $file.sublime-build b/builds/Elixir - elixirc $file.sublime-build new file mode 100644 index 00000000..cbdeb34d --- /dev/null +++ b/builds/Elixir - elixirc $file.sublime-build @@ -0,0 +1,8 @@ +{ + "cmd": ["elixirc", "$file"], + "selector": "source.elixir", + "windows": { + "working_dir": "$file_path", + "cmd": ["elixirc.bat", "$file_name"] + } +} diff --git a/builds/Elixir - mix compile.sublime-build b/builds/Elixir - mix compile.sublime-build new file mode 100644 index 00000000..af94173e --- /dev/null +++ b/builds/Elixir - mix compile.sublime-build @@ -0,0 +1,17 @@ +{ + "cmd": ["mix", "compile"], + "working_dir": "${project_path:${folder}}", + "selector": "source.elixir", + "windows": { + "cmd": ["mix.bat", "compile"] + }, + "variants": [ + { + "name": "Dependencies", + "cmd": ["mix", "deps.compile"], + "windows": { + "cmd": ["mix.bat", "deps.compile"] + } + } + ] +} diff --git a/builds/Elixir - mix test FILE.sublime-build b/builds/Elixir - mix test FILE.sublime-build index 57c68d54..5e3a6434 100644 --- a/builds/Elixir - mix test FILE.sublime-build +++ b/builds/Elixir - mix test FILE.sublime-build @@ -1,5 +1,5 @@ { - "shell_cmd": "cd \"$folder\" && mix test $file", + "target": "mix_test_file", "working_dir": "${project_path}", - "selector": "source.elixir" + "file_patterns": ["*_test.exs"] } diff --git a/builds/Elixir - mix test.sublime-build b/builds/Elixir - mix test.sublime-build index e5300bec..e935df3c 100644 --- a/builds/Elixir - mix test.sublime-build +++ b/builds/Elixir - mix test.sublime-build @@ -1,5 +1,4 @@ { - "shell_cmd": "cd \"$folder\" && mix test", - "working_dir": "${project_path}", - "selector": "source.elixir" + "target": "mix_test", + "working_dir": "${project_path}" } diff --git a/color-schemes/Mariana.sublime-color-scheme b/color-schemes/Mariana.sublime-color-scheme index 3ad80c7c..d959da27 100644 --- a/color-schemes/Mariana.sublime-color-scheme +++ b/color-schemes/Mariana.sublime-color-scheme @@ -6,11 +6,12 @@ }, "rules": [ - { - "name": "EEx tag", - "scope": "punctuation.section.embedded.begin.eex | punctuation.section.embedded.end.eex", - "foreground": "var(blue5)" - }, + // NB: commented out. See: "Embedded punctuation" + // { + // "name": "EEx tag", + // "scope": "punctuation.section.embedded.begin.eex | punctuation.section.embedded.end.eex", + // "foreground": "var(blue5)" + // }, { "name": "EEx embedded", "scope": "source.elixir.embedded.html", @@ -23,9 +24,14 @@ }, { "name": "Doc string", - "scope": "source.markdown.embedded.elixir", + "scope": "meta.doc.elixir meta.string.elixir string | meta.doc meta.string.elixir punctuation.definition.string | meta.doc.elixir storage.type.string.elixir", "foreground": "var(doc)" }, + { + "name": "Doc string escapes", + "scope": "meta.doc.elixir meta.string.elixir constant.character.escape.char.elixir", + "foreground": "color(var(doc) l(+ 15%))" + }, { "name": "Support attributes", "scope": "support.attr.elixir", @@ -55,7 +61,8 @@ { "name": "Capture name", "scope": "variable.other.capture.elixir", - "foreground": "color(var(blue))" + "foreground": "color(var(blue))", + "font_style": "italic" }, { "name": "Capture arity", @@ -102,6 +109,21 @@ "scope": "source.elixir.embedded | source.elixir.interpolated", "background": "color(var(white) a(0.03))" }, + { + "name": "Interpolation punctuation", + "scope": "punctuation.section.interpolation.begin.elixir | punctuation.section.interpolation.end.elixir", + "foreground": "color(var(white) l(- 30%))" + }, + { + "name": "Embedded punctuation", + "scope": "punctuation.section.embedded.begin.elixir | punctuation.section.embedded.end.elixir | punctuation.section.embedded.begin.eex | punctuation.section.embedded.end.eex | punctuation.section.embedded.begin.surface | punctuation.section.embedded.end.surface", + "foreground": "color(var(white) l(- 30%))" + }, + { + "name": "Surface comment punctuation", + "scope": "punctuation.definition.comment.begin.surface | punctuation.definition.comment.end.surface", + "foreground": "color(var(white) l(- 30%))" + }, { "name": "SQL boolean", "scope": "constant.boolean.sql", diff --git a/color-schemes/Monokai.sublime-color-scheme b/color-schemes/Monokai.sublime-color-scheme index da982d12..cc91d13e 100644 --- a/color-schemes/Monokai.sublime-color-scheme +++ b/color-schemes/Monokai.sublime-color-scheme @@ -2,6 +2,7 @@ "name": "Monokai for Elixir", "variables": { + "white3": "#ddd", "entity": "var(yellow2)", "doc": "var(yellow5)" }, @@ -25,9 +26,14 @@ }, { "name": "Doc string", - "scope": "source.markdown.embedded.elixir", + "scope": "meta.doc.elixir meta.string.elixir string | meta.doc meta.string.elixir punctuation.definition.string | meta.doc.elixir storage.type.string.elixir", "foreground": "var(doc)" }, + { + "name": "Doc string escapes", + "scope": "meta.doc.elixir meta.string.elixir constant.character.escape.char.elixir", + "foreground": "color(var(doc) l(+ 15%))" + }, { "name": "Support attributes", "scope": "support.attr.elixir", @@ -63,7 +69,8 @@ { "name": "Capture name", "scope": "variable.other.capture.elixir", - "foreground": "color(var(blue))" + "foreground": "color(var(blue))", + "font_style": "italic" }, { "name": "Capture arity", @@ -110,6 +117,16 @@ "scope": "punctuation.section.interpolation.begin.elixir | punctuation.section.interpolation.end.elixir", "foreground": "color(var(white) l(- 30%))" }, + { + "name": "Embedded punctuation", + "scope": "punctuation.section.embedded.begin.elixir | punctuation.section.embedded.end.elixir | punctuation.section.embedded.begin.eex | punctuation.section.embedded.end.eex | punctuation.section.embedded.begin.surface | punctuation.section.embedded.end.surface", + "foreground": "color(var(white) l(- 30%))" + }, + { + "name": "Surface comment punctuation", + "scope": "punctuation.definition.comment.begin.surface | punctuation.definition.comment.end.surface", + "foreground": "color(var(white) l(- 30%))" + }, { "name": "SQL boolean", "scope": "constant.boolean.sql", diff --git a/commands/Default.sublime-commands b/commands/Default.sublime-commands new file mode 100644 index 00000000..0e355e41 --- /dev/null +++ b/commands/Default.sublime-commands @@ -0,0 +1,22 @@ +[ + { "caption": "ElixirSyntax: Settings", "command": "edit_settings", "args": { + "base_file": "${packages}/ElixirSyntax/settings/ElixirSyntax.sublime-settings", + "default": "{\n $0\n}\n" + } }, + { "caption": "ElixirSyntax: Open Hex Docs", "command": "open_hex_docs" }, + { "caption": "ElixirSyntax: Search Hex Packages", "command": "search_hex_packages" }, + { "caption": "Mix Test: Settings", "command": "mix_test_settings" }, + { "caption": "Mix Test: All", "command": "mix_test" }, + { "caption": "Mix Test: File", "command": "mix_test_file" }, + { "caption": "Mix Test: Selection(s)", "command": "mix_test_selection" }, + { "caption": "Mix Test: Failed", "command": "mix_test_failed" }, + { "caption": "Mix Test: Repeat", "command": "mix_test_repeat" }, + { "caption": "Mix Test: Set --seed", "command": "mix_test_set_seed" }, + { "caption": "Mix Test: Toggle --stale Flag", "command": "mix_test_toggle_stale_flag" }, + { "caption": "Mix Test: Switch to Code or Test", "command": "mix_test_switch_to_code_or_test" }, + { "caption": "Mix Test: Show Panel", "command": "mix_test_show_panel" }, + { "caption": "Mix Test: Hide Panel", "command": "mix_test_hide_panel" }, + { "caption": "Mix Format: File", "command": "mix_format_file" }, + { "caption": "Mix Format: Project / Folder", "command": "mix_format_project" }, + { "caption": "Mix Format: Toggle Auto-Formatting", "command": "mix_format_toggle_auto_format" }, +] diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 00000000..45648323 --- /dev/null +++ b/commands/__init__.py @@ -0,0 +1,3 @@ +from .hex_packages import * +from .mix_test import * +from .mix_format import * diff --git a/commands/hex_packages.py b/commands/hex_packages.py new file mode 100644 index 00000000..11c33554 --- /dev/null +++ b/commands/hex_packages.py @@ -0,0 +1,210 @@ +import sublime +import sublime_plugin +import re +import webbrowser + +from pathlib import Path +from urllib import request +from urllib.error import HTTPError +from datetime import datetime +from .utils import * + +__author__ = 'Aziz Köksal' +__email__ = 'aziz.koeksal@gmail.com' +__status__ = 'Production' + +HEXDOCS_URL = 'https://hexdocs.pm' +HEX_URL = 'https://hex.pm' +ELIXIR_CORE_APP_NAMES = ['eex', 'elixir', 'ex_unit', 'hex', 'iex', 'logger', 'mix'] +name_lastmod_rx = r'hexdocs.pm/([^/]+)/[\s\S]+?([^<]+)' +PROJECT_MAX_AGE_DAYS = 365 + +class SearchHexPackagesCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Searches hex.pm and shows the results.' + + def run(self, **kwargs): + query = (kwargs.get('query') or '').strip() + + if query: + print_status_msg('Searching hex.pm for %r' % query) + sublime.set_timeout_async(lambda: search_hex_pm(self.window, query)) + + def input(self, _args): + class QueryInputHandler(sublime_plugin.TextInputHandler): + def placeholder(self): return 'Search hex.pm' + def validate(self, text): return text.strip() != '' + + return QueryInputHandler() + +class OpenHexDocsCommand(sublime_plugin.WindowCommand): + def description(self): + return 'Finds and opens hex documentation in the browser.' + + def run(self, **_kwargs): + cache_dir = Path(sublime.cache_path(), 'ElixirSyntax') + cache_dir.exists() or cache_dir.mkdir(parents=True) + + cached_sitemap_json_path = Path(cache_dir, 'hexdocs.sitemap.json') + + sitemap_dict = {} + sitemap_url = HEXDOCS_URL + '/sitemap.xml' + + if cached_sitemap_json_path.exists(): + sitemap_dict = load_json_file(cached_sitemap_json_path) + etag = sitemap_dict['etag'] + + def refresh_sitemap(): + try: + resp = request.urlopen(request.Request(sitemap_url, headers={'If-None-Match': etag})) + sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path) + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) + except HTTPError as e: + e.code == 304 or print_status_msg('Error: %s' % e) + + sublime.set_timeout_async(refresh_sitemap) + + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) + else: + print_status_msg('Downloading %r' % sitemap_url) + + def fetch_sitemap(): + try: + resp = request.urlopen(sitemap_url) + sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path) + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) + except HTTPError as e: + print_status_msg('Error: could not fetch %r (status=#%s)' % (sitemap_url, resp.code)) + + sublime.set_timeout_async(fetch_sitemap) + +def search_hex_pm(window, query, **kwargs): + """ Searches hex.pm and shows the results in a quick panel overlay. """ + page = kwargs.get('page') + page_param = page and ['page=%s' % page] or [] + query = query and ''.join('%%%x' % ord(c) if c in '#&/?' else c for c in query) + get_params = '&'.join(['search=%s' % query, 'sort=recent_downloads'] + page_param) + query_url = HEX_URL + '/packages?' + get_params + resp = request.urlopen(query_url) + results_html = resp.read().decode('utf-8') + + package_list_match = re.search(r'
([\s\S]+?)\n
', results_html) + page_match = re.search(r'
  • [\s\S]+?
  • [\s\S]+?\bpage=(\d+)', results_html) + next_page = page_match and int(page_match.group(1)) + total_count_match = re.search(r'packages of (\d+) total', results_html) + total_packages_count = total_count_match and total_count_match.group(1) + + if not package_list_match: + has_no_results = 'no-results' in results_html + + msg = [ + 'could not find div.package-list in the results HTML.', + 'no results found for %r on hex.pm!' % query + ][has_no_results] + + if has_no_results: + overlay_args = {'overlay': 'command_palette', 'command': 'search_hex_packages'} + window.run_command('show_overlay', overlay_args) + window.run_command('insert', {'characters': query}) + + print_status_msg('Error: ' + msg) + return + + package_matches = re.findall(r'''(?xi) + (.+?) [\s\S]*? + total\sdownloads:\s (.+?) [\s\S]*? + (.+?) [\s\S]*? + (.+?) [\s\S]*? +

    ([^<]*)

    + ''', + 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 @@ - + -
    ~H"<.form><%= @input %></.form>" +
    ~H"<.form><%= @input %></.form>"
    diff --git a/images/elixir_json_example.svg b/images/elixir_json_example.svg index a959933a..cfeb0872 100644 --- a/images/elixir_json_example.svg +++ b/images/elixir_json_example.svg @@ -1,4 +1,4 @@ - + -
    ~L"<div><%= @live! %></div>" +
    ~L"<div><%= @live! %></div>"
    diff --git a/images/elixir_regex_example.svg b/images/elixir_regex_example.svg index af821edb..c50be630 100644 --- a/images/elixir_regex_example.svg +++ b/images/elixir_regex_example.svg @@ -1,6 +1,6 @@ - +
    +~H""" + <%!-- Comment --%> +# ^^ punctuation.section.embedded.end +# ^^ punctuation.definition.comment.end +# ^^^ punctuation.definition.comment.begin +# ^^ punctuation.section.embedded.begin +# ^^^^^^^^^^^^^^^^^^ meta.embedded comment.block +
    +# ^^^^^^^^ source.elixir.embedded.html +# ^^^^^^^^^^ meta.attribute-with-value.class.html meta.embedded
    <%= @deploy_step %> # ^^^^^^^^^^^ variable.other.constant @@ -175,13 +183,13 @@ heredoc text ^^text.html.basic """ -~L""" +~H"""
    <%= @var %>
    # ^^^ variable.other.constant.elixir # ^ -string #^^^ entity.name.tag.block.any.html # ^^^^^^ meta.string.elixir -# ^^^^^^^^^^^ meta.interpolation.eex +# ^^^^^^^^^^^ meta.embedded.eex #^^^^ meta.string.elixir \"""m # ^ -storage.type.string @@ -191,16 +199,16 @@ heredoc text # ^ storage.type.string #^^^ punctuation.definition.string.end #^^^^ meta.string.elixir -~L"
    \"
    "m +~H"
    \"
    "m # ^ punctuation.definition.string.end # ^^ constant.character.escape.char.elixir # ^^^ entity.name.tag.block.any.html -~L'
    \'
    'm +~H'
    \'
    'm # ^ punctuation.definition.string.end # ^^ constant.character.escape.char.elixir # ^^^ entity.name.tag.block.any.html - ~L/\//m ~L|\||m ~L{\}}m ~L[\]]m ~L<\>>m ~L(\))m + ~H/\//m ~H|\||m ~H{\}}m ~H[\]]m ~H<\>>m ~H(\))m # ^ string.quoted.modifiers # ^ string.quoted.modifiers # ^ string.quoted.modifiers @@ -208,7 +216,7 @@ heredoc text # ^ string.quoted.modifiers # ^ string.quoted.modifiers -~L''' +~H''' \ ^^ text.html.basic -punctuation.separator.continuation '''m @@ -674,3 +682,33 @@ key: "#{value}\""" # ^^^^ constant.other.symbol.atom # FIXME: ~w"some atoms #{"string"}"a + +@doc ~S"\\\"" +# ^^ constant.character.escape.char +~S"\\\"" +# ^^ constant.character.escape.char +@doc ~S'\\\'' +# ^^ constant.character.escape.char +~S'\\\'' +# ^^ constant.character.escape.char +@doc ~S/\\\// +# ^^ constant.character.escape.char +~S/\\\// +# ^^ constant.character.escape.char +@doc ~S|\\\|| +# ^^ constant.character.escape.char +~S|\\\|| +# ^^ constant.character.escape.char + + ~sql"" +# ^ punctuation.definition.string.end +# ^ punctuation.definition.string.begin +# ^^ string.quoted.double +# ^^^ meta.string.elixir storage.type.string.elixir invalid.illegal.sigil.elixir + + ~SQL'SELECT * FROM table' +# ^ string.quoted.other.literal.upper punctuation.definition.string.end +# ^ punctuation.definition.string.begin +# ^^^^^^^^^^^^^^^^^^^^^ string.quoted.other.literal.upper +#^^^^ storage.type.string +#^^^^^^^^^^^^^^^^^^^^^^^^^ meta.string diff --git a/tests/syntax_test_surface.ex b/tests/syntax_test_surface.ex index e0737d66..7ce0676e 100644 --- a/tests/syntax_test_surface.ex +++ b/tests/syntax_test_surface.ex @@ -6,8 +6,15 @@ #^^ meta.string.elixir storage.type.string.elixir # ^^^ meta.string.elixir punctuation.definition.string.begin.elixir + {!-- Comment --} +# ^^^ punctuation.definition.comment.end.surface +# ^^^^ punctuation.definition.comment.begin.surface +# ^^^^^^^^^^^^^^^^ meta.embedded.surface comment.block.surface + -# ^^^ source.elixir.interpolated.html +# ^ punctuation.section.embedded.end.elixir - source.elixir.embedded +# ^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir - source.elixir.embedded # ^ entity.other.attribute-name.html # ^^^^^^^^^ entity.name.tag.begin.surface # ^^^ entity.name.tag.begin.surface diff --git a/tests/syntax_test_template.eex b/tests/syntax_test_template.eex new file mode 100644 index 00000000..2bb1c8ab --- /dev/null +++ b/tests/syntax_test_template.eex @@ -0,0 +1,78 @@ +# SYNTAX TEST "EEx.sublime-syntax" + +## EEx inside plain text + + <%= if @html do %> +# ^^ punctuation.section.embedded.end.eex +# ^ meta.block.elixir +# ^^ meta.block.elixir punctuation.section.block.begin.elixir keyword.context.block.do.elixir +# ^^^^ variable.other.constant.elixir +# ^ keyword.operator.attribute.elixir +# ^ punctuation.section.arguments.begin.elixir +# ^^^^^^^^^ meta.function-call.arguments.elixir +# ^^ keyword.control.conditional.elixir +#^^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^^^^^^ meta.embedded.eex +
    Some text
    +#^^^^^^^^^^^^^^^^^^^^^^ text.eex + <%= else %> +# ^^ punctuation.section.embedded.end.eex +# ^^^^ keyword.control.conditional.else.elixir +#^^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^ meta.embedded.eex + Some text +# ^^^^^^^^^^ text.eex + <% end %> +# ^^ punctuation.section.embedded.end.eex +# ^^^ keyword.context.block.end.elixir +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^ meta.embedded.eex + + +## Comments + + <% # Comment %> +# ^^ punctuation.section.embedded.end.eex +# ^ punctuation.definition.comment.elixir +# ^^^^^^^^^^ comment.line.number-sign.elixir +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^^^ meta.embedded.eex + <% # Comment +# ^ punctuation.definition.comment.elixir +# ^^^^^^^^^^ comment.line.number-sign.elixir +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^ meta.embedded.eex + x = 1 +# ^ constant.numeric.integer.elixir +# ^ keyword.operator.match.elixir +# ^ variable.other.elixir +#^^^^^^^^^ meta.embedded.eex + # Comment %> +# ^^ punctuation.section.embedded.end.eex +# ^ punctuation.definition.comment.elixir +# ^^^^^^^^^^ comment.line.number-sign.elixir +#^^^^^^^^^^^^^^^ meta.embedded.eex + + + <%# Comment %> +# ^^ punctuation.section.embedded.end.eex +# ^ punctuation.definition.comment.begin.eex +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^^ meta.embedded.eex comment.block.eex + + <%# Comment +# ^ punctuation.definition.comment.begin.eex +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^ meta.embedded.eex comment.block.eex - text.html + %> +#^^ punctuation.section.embedded.end.eex +#^^ meta.embedded.eex comment.block.eex + + <%!-- Comment +# ^^^ punctuation.definition.comment.begin.eex +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^^ meta.embedded.eex comment.block.eex - text.html + --%> +# ^^ punctuation.section.embedded.end.eex +#^^ punctuation.definition.comment.end.eex +#^^^^ meta.embedded.eex comment.block.eex diff --git a/tests/syntax_test_template.ex.eex b/tests/syntax_test_template.ex.eex index d7662a6c..bb28b7cc 100644 --- a/tests/syntax_test_template.ex.eex +++ b/tests/syntax_test_template.ex.eex @@ -18,15 +18,15 @@ defmodule <%= @module %>.View do # ^^^^ entity.name.namespace # ^ punctuation.accessor.dot -# ^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex # ^ keyword.operator.attribute -# ^^ entity.name.tag.ex.eex +# ^^^ punctuation.section.embedded.begin.eex end alias <%= @web_namespace %>.Router.Helpers, as: Routes # ^^^^^^ entity.name.namespace # ^ punctuation.accessor.dot -# ^^ entity.name.tag.ex.eex +# ^^^ punctuation.section.embedded.begin.eex :<%= @key %> #<- constant.other.symbol punctuation.definition.constant.begin @@ -41,50 +41,64 @@ alias <%= @web_namespace %>.Router.Helpers, as: Routes # are also highlighted inside strings. # FIXME: make negative check with "-entity" when solved. "<%= string %>" -# ^ entity.name.tag.ex.eex -# ^^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex +#^^^ punctuation.section.embedded.begin.eex M1.<%= M2 %>.f() # ^ punctuation.section.arguments.end # ^ punctuation.section.arguments.begin # ^ variable.function # ^ punctuation.accessor.dot -# ^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex # ^^ constant.other.module -# ^^ entity.name.tag.ex.eex +# ^^^ punctuation.section.embedded.begin.eex # ^ punctuation.accessor.dot x.<%= :member %>() # ^ punctuation.section.arguments.end # ^ punctuation.section.arguments.begin -# ^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex # ^^^^^^^ constant.other.symbol -# ^^ entity.name.tag.ex.eex +# ^^^ punctuation.section.embedded.begin.eex #^ punctuation.accessor.dot - ^ punctuation.section.arguments.end - ^ punctuation.section.arguments.begin @type <%= :t %> :: any -# ^ entity.name.tag.ex.eex -# ^^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex +# ^^^ punctuation.section.embedded.begin.eex ^^^ support.type @spec <%= :name %>(any) # ^^^ support.type -# ^ entity.name.tag.ex.eex -# ^^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex +# ^^^ punctuation.section.embedded.begin.eex ^ punctuation.definition.parameters.end ^ punctuation.definition.parameters.begin &<%= %> -# ^ entity.name.tag.ex.eex -# ^^ entity.name.tag.ex.eex +# ^^ punctuation.section.embedded.end.eex +# ^^^ punctuation.section.embedded.begin.eex #^ keyword.operator.capture # <%= @web_app_name %> -# ^ punctuation.section.embedded.end.ex.eex -# ^ entity.name.tag.ex.eex -# ^^ entity.name.tag.ex.eex -# ^ punctuation.section.embedded.begin.ex.eex +# ^^ punctuation.section.embedded.end.eex +# ^^^ punctuation.section.embedded.begin.eex #^^^^^^^^^^^^^^^^^^^^^^ comment.line.number-sign.elixir #<- comment.line.number-sign.elixir punctuation.definition.comment.elixir + + + <%# Comment +# ^ punctuation.definition.comment.begin.eex +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^ meta.embedded.eex comment.block.eex - text.html + %> +#^^ punctuation.section.embedded.end.eex +#^^ meta.embedded.eex comment.block.eex - text.html + + <%!-- Comment +# ^^^ punctuation.definition.comment.begin.eex +#^^ punctuation.section.embedded.begin.eex +#^^^^^^^^^^^^^^ meta.embedded.eex comment.block.eex - text.html + --%> +# ^^ punctuation.section.embedded.end.eex +#^^ punctuation.definition.comment.end.eex +#^^^^ meta.embedded.eex comment.block.eex - text.html diff --git a/tests/syntax_test_template.html.eex b/tests/syntax_test_template.html.eex index 53089896..11d7c3fd 100644 --- a/tests/syntax_test_template.html.eex +++ b/tests/syntax_test_template.html.eex @@ -3,56 +3,145 @@ "> + + + + + - - - - + + + - <%# Comment %> - - - - + <% # Comment %> + + + + + + + + <%# Comment + + + + Block + + %> + + + +
    + + + + + + + + + + + + <<%= @tag %> attr="" attr=<%= @value %> <%= @attr %>="" <%= @attr %>=<%= @value %>> + + + + + + + + + + + + + + + + + + + + + > + + + + + + + + + + + < <%= @tag %> <%= @attr %>> + + + > + + + -name/> + + + + + + + + =""/> + + + + + /> + + + + <% func arg %> - - + + + + - - - + <%= if true? do %> - - + + + + - - + <% end %> - - + + + + - - + <%% quoted :code %> - - + + + + - - + <%%= quoted :result %> - - + + + + - - + diff --git a/tests/syntax_test_template.html.heex b/tests/syntax_test_template.html.heex index 14a8e136..cca83ccf 100644 --- a/tests/syntax_test_template.html.heex +++ b/tests/syntax_test_template.html.heex @@ -2,78 +2,283 @@ + - - - + + + + + + + + + + + + <%!-- Multi-line + + + + + comment --%> + + + + + + <% # Comment %> + + + + + + + <%# Comment %> - - - - + + + + + + + + {"1" <> "2"} + + + + + + + + + <:col :let={user}><%= user.id %>
    + + + + + + + + + + + + + - <.form> - + +
    + + + + + + + +
    + + +

    + + + + + + + + + + + + + + + <.table rows={@users}> + + + + + + + + - - + + + + + <:col let={user} label="Name"> + + + + + + + <%= user.name %> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <{=@a}a /> + <% func arg %> - - + + + + - - - + <%= if true? do %> - - + + + + - - + <% end %> - - + + + + - - + <%% quoted :code %> - - + + + + - - + <%%= quoted :result %> - - + + + + - - + diff --git a/tests/syntax_test_template.sface b/tests/syntax_test_template.sface index 6cb9998a..8a98919a 100644 --- a/tests/syntax_test_template.sface +++ b/tests/syntax_test_template.sface @@ -1,55 +1,97 @@ # SYNTAX TEST "HTML (Surface).sublime-syntax" + +# ^ invalid +# ^ invalid +# ^^^^^^^^ - meta.embedded +# ^^^^^^ meta.embedded.surface + <%# No EEx comment %> +# ^^^^^^^^^^^^^^^^^^^^^ meta.tag.other.surface - meta.embedded - comment + +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^ meta.embedded - comment +# ^^^^^^^^^^^^^^^^^^^^ meta.string.html - meta.embedded - comment + + # ^^^^^^^^^^ string.quoted.double # ^ punctuation.separator.key-value # ^^ entity +# ^ meta.embedded.surface punctuation.section.embedded.end.elixir - source # ^^^^ constant.other.keyword # ^^^^^^ constant.other.keyword -# ^^^^^^^^^^^^^^^^^^^^^^^^ source.elixir.interpolated.html +# ^^^^^^^^^^^^^^^^^^^^^^ meta.embedded.surface source.elixir.embedded.html +# ^ meta.embedded.surface punctuation.section.embedded.begin.elixir - source +# ^ punctuation.definition.attribute.begin.surface #^^^^^^ entity.name.tag.begin.surface {#for i <- 1..max} -# ^^^^^^^^^^^^ meta.function-call.arguments.elixir -# ^^^ entity.name.tag.block.surface -# ^^^^^^^^^^^^^^^^^^ source.elixir.embedded.html - +# ^^^^^^^^^^^^ source.elixir.embedded.html meta.function-call.arguments.elixir +# ^^^ keyword.control.loop.surface +# ^^^^^^^^^^^^^^^^^^ meta.embedded.surface + +# ^^^^^^^^ meta.attribute-with-value.style.html meta.embedded.surface +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ meta.attribute-with-value.class.html meta.embedded.surface +# ^^^^^ meta.attribute-with-value.id.html meta.embedded.surface +# ^^ meta.tag.inline.any.html meta.attribute-with-value entity.other.attribute-name -# ^^^^^^^^^ variable.other.constant.elixir -# ^ keyword.operator.attribute.elixir -# ^^^^^^^^^^^^ source.elixir.interpolated.html +# ^ meta.attribute-with-value.class.html meta.string.html - meta.embedded +# ^ - punctuation.section.embedded.end.elixir - source +# ^^^^^^^^^ - variable.other.constant.elixir +# ^ - keyword.operator.attribute.elixir +# ^^^^^^^^^^ - source +# ^ - punctuation.section.embedded.begin.elixir - source +# ^^^^^^^^^^^^^^^ meta.attribute-with-value.class.html meta.class-name.html meta.string.html - meta.embedded.surface +# ^^^^^^^^ meta.attribute-with-value.class.html meta.string.html - meta.embedded {i + 1} -# ^^^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.end.elixir - source +# ^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir - source +# ^^^^^^^ meta.embedded.surface {#else} -# ^^^^ entity.name.tag.block.surface +# ^^^^ keyword.control.conditional.surface {#elsei} -# ^^^^^ entity.name.tag.block.surface +# ^^^^^ keyword.control.surface {#elseif x == 0} +# ^ punctuation.section.embedded.end.surface # ^^^^^^ source.elixir.embedded.html -# ^^^^^^ entity.name.tag.block.surface +# ^^^^^^ keyword.control.conditional.surface +# ^^ punctuation.section.embedded.begin.surface +# ^^^^^^^^^^^^^^^^ meta.embedded.surface {/for} -# ^^^ entity.name.tag.block.surface +# ^ punctuation.section.embedded.end.surface +# ^^^ keyword.control.loop.surface +# ^^ punctuation.section.embedded.begin.surface +# ^^^^^^ meta.embedded.surface # ^^^^^^ entity.name.tag.end.surface -# ^^^^^^^^^ variable.function.surface -entity.name +# ^^^^^^^^^ variable.function.surface - entity.name +#^^^^^^^^^^^^^^^^^^^^^^^ meta.tag.other.surface # ^^^^^^^^^ entity.name.tag.begin.surface # ^ punctuation.accessor.dot.surface # ^^^ entity.name.tag.begin.surface # ^ punctuation.accessor.dot.surface #^^^^ entity.name.tag.begin.surface +#^^^^^^^^^^^^^^^^^^^^^^^^^ meta.tag.other.surface {#case @value} +# ^^^^^ variable.other.constant.elixir +# ^^^^ keyword.control.conditional.surface +# ^ punctuation.section.embedded.end.surface +# ^^punctuation.section.embedded.begin.surface +# ^^^^^^^^^^^^^^ meta.embedded.surface {#match [{_, first} | _]} -# ^ punctuation.definition.tag.end.surface +# ^ punctuation.section.embedded.end.surface # ^ punctuation.section.sequence.end.elixir # ^^^^^ variable.parameter.elixir # ^ punctuation.section.sequence.begin.elixir -# ^^ punctuation.definition.tag.begin.surface +# ^^ punctuation.section.embedded.begin.surface First {first} -# ^^^^^ variable.other.elixir -# ^^^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.end.elixir - source +# ^^^^^ source.elixir.embedded.html variable.other.elixir +# ^ punctuation.section.embedded.begin.elixir - source +# ^^^^^^^ meta.embedded.surface {#match []} # ^^ meta.brackets.elixir Value is empty @@ -59,79 +101,102 @@ {/case} # ^ punctuation.definition.tag.end.html -# ^ -punctuation.definition.tag.end.html +# ^ - punctuation.definition.tag.end.html # ^^^^^^^^^ entity.name.tag.end.surface # ^ punctuation.accessor.dot.surface # ^^^ entity.name.tag.end.surface # ^ punctuation.accessor.dot.surface # ^^^^ entity.name.tag.end.surface +#^^^^^^^^^^^^^^^^^^^^^ meta.tag.other.surface -# ^ punctuation.section.interpolation.end.elixir +# ^ punctuation.section.embedded.end.elixir # ^^^^ variable.other.constant.elixir # ^ keyword.operator.attribute.elixir -# ^ punctuation.section.interpolation.begin.elixir -# ^^^^^^^ source.elixir.interpolated.html +# ^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir # ^ punctuation.separator.key-value.html # ^^^^ entity.other.attribute-name.html -# ^ punctuation.section.interpolation.end.elixir +# ^ punctuation.section.embedded.end.elixir # ^^^^ variable.other.constant.elixir # ^ keyword.operator.attribute.elixir # ^ keyword.operator.match.elixir -# ^ punctuation.section.interpolation.begin.elixir -# ^^^^^^^^ source.elixir.interpolated.html +# ^^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir <#slot :args={value: @value, max: @max} /> -# ^^^^^^^^^^^^^^^^^^^^^^^^^^ source.elixir.interpolated.html +# ^ punctuation.section.embedded.end.elixir +# ^^^^^^^^^^^^^^^^^^^^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir +# ^^^^^^^^^^^^^^^^^^^^^^^^^^ meta.attribute-with-value.html meta.embedded.surface - string # ^ punctuation.separator.key-value.html # ^^^^ entity.other.attribute-name.surface +# ^ punctuation.definition.attribute.begin.surface +# ^^^^^^ meta.tag.other.surface meta.attribute-with-value.html - meta.string - meta.embedded #^^^^^ entity.name.tag.begin.surface +#^^^^^^ meta.tag.other.surface - meta.attribute-with-value <:slot> # ^^^^^ entity.name.tag.end.surface #^^^^^ entity.name.tag.begin.surface +#^^^^^^^^^^^^^^ meta.tag.other.surface - meta.attribute-with-value <#Raw> # ^ punctuation.definition.tag.end.html # ^^^^ entity.name.tag.begin.surface # ^ punctuation.definition.tag.begin.html +# ^^^^^^ meta.tag.other.surface - meta.attribute-with-value <#Raw> -# ^ -punctuation.definition.tag.end.html -# ^^^^ -entity.name.tag.begin.surface -# ^ -punctuation.definition.tag.begin.html +# ^ - punctuation +# ^^^^ - entity +# ^ - punctuation <:slot args={@args}> -# ^^^^^ -entity.name -# ^^^^^^^^^^^^ -variable -entity -punctuation -# ^^^^^ -entity.name - -# ^^^^ entity.name.tag.inline.any.html -# ^^^^^^^^ -source.elixir.interpolated +# ^^^^^ - entity +# ^^^^^^^^^^^^ - variable - entity - punctuation +# ^^^^^ - entity + +# ^^^^ entity.name.tag.inline.any.html +# ^^^^^^^^ - source.elixir +# ^^^^^^^^ - source.elixir +# ^^^^^^^^ - source.elixir # ^^^^ entity.name.tag.inline.any.html # ^ punctuation.definition.tag.end.html # ^^^^ entity.name.tag.end.surface # ^^ punctuation.definition.tag.begin.html +# ^^^^^^^ meta.tag.other.surface - meta.attribute-with-value <#Markdown class="content" opts={x: "y"}> # ^ punctuation.definition.tag.end.html -# ^^^^^^^^ source.elixir.interpolated.html +# ^ meta.tag.other.surface - meta.attribute-with-value +# ^ punctuation.section.embedded.end.elixir - source +# ^^^^^^ source.elixir.embedded.html +# ^ punctuation.section.embedded.begin.elixir - source +# ^^^^^^^^^^^^^ meta.tag.other.surface meta.attribute-with-value.html +# ^ meta.tag.other.surface - meta.attribute-with-value # ^^^^^^^^^ string.quoted.double.html +# ^^^^^^^^^^^^^^^ meta.tag.other.surface meta.attribute-with-value.class.html # ^^^^^ entity.other.attribute-name.class.html # ^^^^^^^^^ entity.name.tag.begin.surface # ^ punctuation.definition.tag.begin.html +# ^^^^^^^^^^^ meta.tag.other.surface - meta.attribute-with-value # Markdown <#Markdown> -#^^^^^^^^^ -entity.name.tag.end.surface +#^^^^^^^^^ - entity.name.tag.end.surface # ^^^^^^^^^ entity.name.tag.end.surface +#^^^^^^^^^^^ meta.tag.other.surface +# ^ - meta.tag # ^^^^ entity.name.tag.inline.any.html # ^^^^ entity.name.tag.inline.any.html +# ^ - meta.tag # ^ punctuation.definition.tag.end.html # ^^^^^^^^^ entity.name.tag.end.surface # ^^ punctuation.definition.tag.begin.surface +# ^^^^^^^^^^^^ meta.tag.other.surface