Skip to content

Commit 14485d1

Browse files
azizkprincemaple
authored andcommitted
Commands: added SearchHexPackages and OpenHexDocs.
1 parent 9a5f3b6 commit 14485d1

File tree

4 files changed

+215
-0
lines changed

4 files changed

+215
-0
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Use the default shortcut `Alt+Shift+F` or the palette command `Mix Format: File`
8383
## Palette commands
8484

8585
- `ElixirSyntax: Settings`
86+
- `ElixirSyntax: Open Hex Docs`
87+
- `ElixirSyntax: Search Hex Packages`
8688
- `Mix Test: Settings`
8789
- `Mix Test: All`
8890
- `Mix Test: File`

commands/Default.sublime-commands

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"base_file": "${packages}/ElixirSyntax/settings/ElixirSyntax.sublime-settings",
44
"default": "{\n $0\n}\n"
55
} },
6+
{ "caption": "ElixirSyntax: Open Hex Docs", "command": "open_hex_docs" },
7+
{ "caption": "ElixirSyntax: Search Hex Packages", "command": "search_hex_packages" },
68
{ "caption": "Mix Test: Settings", "command": "mix_test_settings" },
79
{ "caption": "Mix Test: All", "command": "mix_test" },
810
{ "caption": "Mix Test: File", "command": "mix_test_file" },

commands/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from .hex_packages import *
12
from .mix_test import *
23
from .mix_format import *

commands/hex_packages.py

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import sublime
2+
import sublime_plugin
3+
import re
4+
import webbrowser
5+
6+
from pathlib import Path
7+
from urllib import request
8+
from urllib.error import HTTPError
9+
from datetime import datetime
10+
from .utils import *
11+
12+
__author__ = 'Aziz Köksal'
13+
__email__ = '[email protected]'
14+
__status__ = 'Production'
15+
16+
HEXDOCS_URL = 'https://hexdocs.pm'
17+
HEX_URL = 'https://hex.pm'
18+
ELIXIR_CORE_APP_NAMES = ['eex', 'elixir', 'ex_unit', 'hex', 'iex', 'logger', 'mix']
19+
name_lastmod_rx = r'hexdocs.pm/([^/]+)/[\s\S]+?<lastmod>([^<]+)</lastmod>'
20+
PROJECT_MAX_AGE_DAYS = 365
21+
22+
class SearchHexPackagesCommand(sublime_plugin.WindowCommand):
23+
def description(self):
24+
return 'Searches hex.pm and shows the results.'
25+
26+
def run(self, **kwargs):
27+
query = (kwargs.get('query') or '').strip()
28+
29+
if query:
30+
print_status_msg('Searching hex.pm for %r' % query)
31+
sublime.set_timeout_async(lambda: search_hex_pm(self.window, query))
32+
33+
def input(self, _args):
34+
class QueryInputHandler(sublime_plugin.TextInputHandler):
35+
def placeholder(self): return 'Search hex.pm'
36+
def validate(self, text): return text.strip() != ''
37+
38+
return QueryInputHandler()
39+
40+
class OpenHexDocsCommand(sublime_plugin.WindowCommand):
41+
def description(self):
42+
return 'Finds and opens hex documentation in the browser.'
43+
44+
def run(self, **_kwargs):
45+
cache_dir = Path(sublime.cache_path(), 'ElixirSyntax')
46+
cache_dir.exists() or cache_dir.mkdir(parents=True)
47+
48+
cached_sitemap_json_path = Path(cache_dir, 'hexdocs.sitemap.json')
49+
50+
sitemap_dict = {}
51+
sitemap_url = HEXDOCS_URL + '/sitemap.xml'
52+
53+
if cached_sitemap_json_path.exists():
54+
sitemap_dict = load_json_file(cached_sitemap_json_path)
55+
etag = sitemap_dict['etag']
56+
57+
def refresh_sitemap():
58+
try:
59+
resp = request.urlopen(request.Request(sitemap_url, headers={'If-None-Match': etag}))
60+
sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path)
61+
show_hexdocs_list(self.window, sitemap_dict.get('projects', []))
62+
except HTTPError as e:
63+
e.code == 304 or print_status_msg('Error: %s' % e)
64+
65+
sublime.set_timeout_async(refresh_sitemap)
66+
67+
show_hexdocs_list(self.window, sitemap_dict.get('projects', []))
68+
else:
69+
print_status_msg('Downloading %r' % sitemap_url)
70+
71+
def fetch_sitemap():
72+
try:
73+
resp = request.urlopen(sitemap_url)
74+
sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path)
75+
show_hexdocs_list(self.window, sitemap_dict.get('projects', []))
76+
except HTTPError as e:
77+
print_status_msg('Error: could not fetch %r (status=#%s)' % (sitemap_url, resp.code))
78+
79+
sublime.set_timeout_async(fetch_sitemap)
80+
81+
def search_hex_pm(window, query, **kwargs):
82+
""" Searches hex.pm and shows the results in a quick panel overlay. """
83+
page = kwargs.get('page')
84+
page_param = page and ['page=%s' % page] or []
85+
query = query and ''.join('%%%x' % ord(c) if c in '#&/?' else c for c in query)
86+
get_params = '&'.join(['search=%s' % query, 'sort=recent_downloads'] + page_param)
87+
query_url = HEX_URL + '/packages?' + get_params
88+
resp = request.urlopen(query_url)
89+
results_html = resp.read().decode('utf-8')
90+
91+
package_list_match = re.search(r'<div class="package-list">([\s\S]+?)\n</div>', results_html)
92+
page_match = re.search(r'<li class="active">[\s\S]+?</li>[\s\S]+?\bpage=(\d+)', results_html)
93+
next_page = page_match and int(page_match.group(1))
94+
total_count_match = re.search(r'packages of (\d+) total', results_html)
95+
total_packages_count = total_count_match and total_count_match.group(1)
96+
97+
if not package_list_match:
98+
has_no_results = 'no-results' in results_html
99+
100+
msg = [
101+
'could not find div.package-list in the results HTML.',
102+
'no results found for %r on hex.pm!' % query
103+
][has_no_results]
104+
105+
if has_no_results:
106+
overlay_args = {'overlay': 'command_palette', 'command': 'search_hex_packages'}
107+
window.run_command('show_overlay', overlay_args)
108+
window.run_command('insert', {'characters': query})
109+
110+
print_status_msg('Error: ' + msg)
111+
return
112+
113+
package_matches = re.findall(r'''(?xi)
114+
<span\sclass="download-count"> (.+?) </span> [\s\S]*?
115+
<span.+?> total\sdownloads:\s (.+?) </span> [\s\S]*?
116+
<a.+?> (.+?) </a> [\s\S]*?
117+
<span.+?> (.+?) </span> [\s\S]*?
118+
<p> ([^<]*) </p>
119+
''',
120+
package_list_match.group(1)
121+
)
122+
123+
previous_results = kwargs.get('previous_results') or []
124+
125+
results = previous_results + [
126+
{'name': m[2], 'desc': m[4], 'version': m[3], 'recent_dls': m[0], 'total_dls': m[1],
127+
'url': HEX_URL + '/packages/' + m[2]}
128+
for m in package_matches
129+
]
130+
131+
selectable_results = results + [
132+
{'label': 'Open search query in browser', 'url': query_url, 'desc': 'Terms: %s' % query},
133+
] + (
134+
next_page and [{
135+
'label': 'Load page %d' % next_page,
136+
'page': next_page,
137+
'desc': 'Total packages found: %s' % (total_packages_count or 'unknown')
138+
}] or []
139+
)
140+
141+
def on_select(i):
142+
if i >= 0:
143+
result = selectable_results[i]
144+
if result.get('page'):
145+
print_status_msg('Loading page %d on hex.pm for %r' % (next_page, query))
146+
cb = lambda: search_hex_pm(window, query, page=next_page, previous_results=results)
147+
sublime.set_timeout_async(cb)
148+
else:
149+
webbrowser.open_new_tab(result['url'])
150+
151+
placeholder = 'Open a project in the web browser.'
152+
selected_index = len(previous_results) if previous_results else -1
153+
154+
result_items = [
155+
sublime.QuickPanelItem(
156+
trigger=result.get('label') or '%s v%s' % (result['name'], result['version']),
157+
details=result.get('desc') or '',
158+
annotation=result.get('recent_dls') \
159+
and '%s recent / %s total downloads' % (result['recent_dls'], result['total_dls']) \
160+
or '',
161+
kind=result.get('recent_dls') and sublime.KIND_NAVIGATION or sublime.KIND_AMBIGUOUS
162+
)
163+
for result in selectable_results
164+
]
165+
166+
window.show_quick_panel(result_items, on_select,
167+
placeholder=placeholder, selected_index=selected_index
168+
)
169+
170+
def fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path):
171+
""" Fetches, parses and saves the sitemap items in a JSON file. """
172+
etag = next(
173+
(value for (header, value) in resp.headers.items() if header.lower() == 'etag'), None
174+
)
175+
176+
sitemap_xml = resp.read().decode('utf-8')
177+
elixir_core_projects = [(name, None) for name in ELIXIR_CORE_APP_NAMES]
178+
hexdocs_projects = re.findall(name_lastmod_rx, sitemap_xml)
179+
young_projects, old_projects, now = [], [], datetime.now()
180+
181+
for name, date in hexdocs_projects:
182+
parsed_date = datetime.strptime(date[:10], '%Y-%m-%d')
183+
younger_than_x_days = (now - parsed_date).days <= PROJECT_MAX_AGE_DAYS
184+
(young_projects if younger_than_x_days else old_projects).append((name, date))
185+
186+
projects = sorted(young_projects + elixir_core_projects) + old_projects
187+
projects = [{'name': name, 'lastmod': lastmod} for (name, lastmod) in projects]
188+
sitemap_dict = {'projects': projects, 'etag': etag}
189+
save_json_file(cached_sitemap_json_path, sitemap_dict)
190+
191+
return sitemap_dict
192+
193+
def show_hexdocs_list(window, projects):
194+
""" Shows the hexdocs projects in a quick panel overlay. """
195+
project_items = [
196+
sublime.QuickPanelItem(
197+
trigger=project['name'],
198+
details=project['lastmod'] \
199+
and 'Last modified: %s' % project['lastmod'][:-4].replace('T', ' ') \
200+
or '',
201+
kind=sublime.KIND_NAVIGATION
202+
)
203+
for project in projects
204+
]
205+
206+
def on_select(i):
207+
i >= 0 and webbrowser.open_new_tab(HEXDOCS_URL + '/' + projects[i]['name'])
208+
209+
placeholder = 'Open a project\'s documentation in the web browser.'
210+
window.show_quick_panel(project_items, on_select, placeholder=placeholder)

0 commit comments

Comments
 (0)