1from __future__ import unicode_literals
2from __future__ import print_function
3import binascii
4
5try:
6    from html import escape as escape_html
7except ImportError:
8    from cgi import escape as escape_html
9import glob
10import multiprocessing.pool
11import operator
12import os
13import platform
14import re
15import select
16import socket
17import subprocess
18import sys
19import tempfile
20import threading
21from itertools import chain
22
23COMMON_WSL_CMD_PATHS = (
24    "/mnt/c/Windows/System32",
25    "/windir/c/Windows/System32",
26    "/c/Windows/System32",
27)
28FISH_BIN_PATH = False  # will be set later
29IS_PY2 = sys.version_info[0] == 2
30
31if IS_PY2:
32    import SimpleHTTPServer
33    import SocketServer
34    from urlparse import parse_qs
35else:
36    import http.server as SimpleHTTPServer
37    import socketserver as SocketServer
38    from urllib.parse import parse_qs
39
40try:
41    import json
42except ImportError:
43    import simplejson as json
44
45
46# Disable CLI web browsers
47term = os.environ.pop("TERM", None)
48# This import must be done with an empty $TERM, otherwise a command-line browser may be started
49# which will block the whole process - see https://docs.python.org/3/library/webbrowser.html
50import webbrowser
51
52if term:
53    os.environ["TERM"] = term
54
55
56def find_executable(exe, paths=()):
57    final_path = os.environ["PATH"].split(os.pathsep)
58    if paths:
59        final_path.extend(paths)
60    for p in final_path:
61        proposed_path = os.path.join(p, exe)
62        if os.access(proposed_path, os.X_OK):
63            return proposed_path
64
65
66def isMacOS10_12_5_OrLater():
67    """ Return whether this system is macOS 10.12.5 or a later version. """
68    try:
69        return [int(x) for x in platform.mac_ver()[0].split(".")] >= [10, 12, 5]
70    except:
71        return False
72
73
74def is_wsl():
75    """ Return whether we are running under the Windows Subsystem for Linux """
76    if "linux" in platform.system().lower() and os.access("/proc/version", os.R_OK):
77        with open("/proc/version", "r") as f:
78            # Find 'Microsoft' for wsl1 and 'microsoft' for wsl2
79            if "microsoft" in f.read().lower():
80                return True
81    return False
82
83
84def is_termux():
85    """ Return whether we are running under the Termux application for Android"""
86    return "com.termux" in os.environ["PATH"] and find_executable("termux-open-url")
87
88
89def is_chromeos_garcon():
90    """ Return whether we are running in Chrome OS and the browser can't see local files """
91    # In Crostini Chrome OS Linux, the default browser opens URLs in Chrome
92    # running outside the linux VM. This browser does not have access to the
93    # Linux filesystem. This uses Garcon, see for example
94    # https://chromium.googlesource.com/chromiumos/platform2/+/master/vm_tools/garcon/#opening-urls
95    # https://source.chromium.org/search?q=garcon-url-handler
96    return "garcon-url-handler" in webbrowser.get().name
97
98
99def run_fish_cmd(text):
100    # Ensure that fish is using UTF-8.
101    ctype = os.environ.get("LC_ALL", os.environ.get("LC_CTYPE", os.environ.get("LANG")))
102    env = None
103    if ctype is None or re.search(r"\.utf-?8$", ctype, flags=re.I) is None:
104        # override LC_CTYPE with en_US.UTF-8
105        # We're assuming this locale exists.
106        # Fish makes the same assumption in config.fish
107        env = os.environ.copy()
108        env.update(LC_CTYPE="en_US.UTF-8", LANG="en_US.UTF-8")
109
110    print("$ " + text)
111    p = subprocess.Popen(
112        [FISH_BIN_PATH],
113        stdin=subprocess.PIPE,
114        stdout=subprocess.PIPE,
115        stderr=subprocess.PIPE,
116        env=env,
117    )
118    out, err = p.communicate(text.encode("utf-8"))
119    out = out.decode("utf-8", "replace")
120    err = err.decode("utf-8", "replace")
121    return out, err
122
123
124def escape_fish_cmd(text):
125    # Replace one backslash with two, and single quotes with backslash-quote
126    escaped = text.replace("\\", "\\\\").replace("'", "\\'")
127    return "'" + escaped + "'"
128
129
130named_colors = {
131    "black": "000000",
132    "red": "800000",
133    "green": "008000",
134    "brown": "725000",
135    "yellow": "808000",
136    "blue": "000080",
137    "magenta": "800080",
138    "purple": "800080",
139    "cyan": "008080",
140    "grey": "e5e5e5",
141    "brgrey": "555555",
142    "white": "c0c0c0",
143    "brblack": "808080",
144    "brred": "ff0000",
145    "brgreen": "00ff00",
146    "brbrown": "ffff00",
147    "bryellow": "ffff00",
148    "brblue": "0000ff",
149    "brmagenta": "ff00ff",
150    "brpurple": "ff00ff",
151    "brcyan": "00ffff",
152    "brwhite": "ffffff",
153}
154
155bindings_blacklist = set(["self-insert", "'begin;end'"])
156
157
158def parse_one_color(comp):
159    """ A basic function to parse a single color value like 'FFA000' """
160    if comp in named_colors:
161        # Named color
162        return named_colors[comp]
163    elif (
164        re.match(r"[0-9a-fA-F]{3}", comp) is not None
165        or re.match(r"[0-9a-fA-F]{6}", comp) is not None
166    ):
167        # Hex color
168        return comp
169    else:
170        # Unknown
171        return ""
172
173
174def better_color(c1, c2):
175    """ Indicate which color is "better", i.e. prefer term256 colors """
176    if not c2:
177        return c1
178    if not c1:
179        return c2
180    if c1 == "normal":
181        return c2
182    if c2 == "normal":
183        return c1
184    if c2 in named_colors:
185        return c1
186    if c1 in named_colors:
187        return c2
188    return c1
189
190
191def parse_color(color_str):
192    """A basic function to parse a color string, for example, 'red' '--bold'."""
193    comps = color_str.split(" ")
194    color = "normal"
195    background_color = ""
196    bold, underline, italics, dim, reverse = False, False, False, False, False
197    for comp in comps:
198        # Remove quotes
199        comp = comp.strip("'\" ")
200        if comp == "--bold":
201            bold = True
202        elif comp == "--underline":
203            underline = True
204        elif comp == "--italics":
205            italics = True
206        elif comp == "--dim":
207            dim = True
208        elif comp == "--reverse":
209            reverse = True
210        elif comp.startswith("--background="):
211            # Background color
212            background_color = better_color(
213                background_color, parse_one_color(comp[len("--background=") :])
214            )
215        else:
216            # Regular color
217            color = better_color(color, parse_one_color(comp))
218
219    return {
220        "color": color,
221        "background": background_color,
222        "bold": bold,
223        "underline": underline,
224        "italics": italics,
225        "dim": dim,
226        "reverse": reverse,
227    }
228
229
230def parse_bool(val):
231    val = val.lower()
232    if val.startswith("f") or val.startswith("0"):
233        return False
234    if val.startswith("t") or val.startswith("1"):
235        return True
236    return bool(val)
237
238
239def html_color_for_ansi_color_index(val):
240    arr = [
241        "black",
242        "#FF0000",
243        "#00FF00",
244        "#AA5500",
245        "#0000FF",
246        "#AA00AA",
247        "#00AAAA",
248        "#AAAAAA",
249        "#555555",
250        "#FF5555",
251        "#55FF55",
252        "#FFFF55",
253        "#5555FF",
254        "#FF55FF",
255        "#55FFFF",
256        "white",
257        "#000000",
258        "#00005f",
259        "#000087",
260        "#0000af",
261        "#0000d7",
262        "#0000ff",
263        "#005f00",
264        "#005f5f",
265        "#005f87",
266        "#005faf",
267        "#005fd7",
268        "#005fff",
269        "#008700",
270        "#00875f",
271        "#008787",
272        "#0087af",
273        "#0087d7",
274        "#0087ff",
275        "#00af00",
276        "#00af5f",
277        "#00af87",
278        "#00afaf",
279        "#00afd7",
280        "#00afff",
281        "#00d700",
282        "#00d75f",
283        "#00d787",
284        "#00d7af",
285        "#00d7d7",
286        "#00d7ff",
287        "#00ff00",
288        "#00ff5f",
289        "#00ff87",
290        "#00ffaf",
291        "#00ffd7",
292        "#00ffff",
293        "#5f0000",
294        "#5f005f",
295        "#5f0087",
296        "#5f00af",
297        "#5f00d7",
298        "#5f00ff",
299        "#5f5f00",
300        "#5f5f5f",
301        "#5f5f87",
302        "#5f5faf",
303        "#5f5fd7",
304        "#5f5fff",
305        "#5f8700",
306        "#5f875f",
307        "#5f8787",
308        "#5f87af",
309        "#5f87d7",
310        "#5f87ff",
311        "#5faf00",
312        "#5faf5f",
313        "#5faf87",
314        "#5fafaf",
315        "#5fafd7",
316        "#5fafff",
317        "#5fd700",
318        "#5fd75f",
319        "#5fd787",
320        "#5fd7af",
321        "#5fd7d7",
322        "#5fd7ff",
323        "#5fff00",
324        "#5fff5f",
325        "#5fff87",
326        "#5fffaf",
327        "#5fffd7",
328        "#5fffff",
329        "#870000",
330        "#87005f",
331        "#870087",
332        "#8700af",
333        "#8700d7",
334        "#8700ff",
335        "#875f00",
336        "#875f5f",
337        "#875f87",
338        "#875faf",
339        "#875fd7",
340        "#875fff",
341        "#878700",
342        "#87875f",
343        "#878787",
344        "#8787af",
345        "#8787d7",
346        "#8787ff",
347        "#87af00",
348        "#87af5f",
349        "#87af87",
350        "#87afaf",
351        "#87afd7",
352        "#87afff",
353        "#87d700",
354        "#87d75f",
355        "#87d787",
356        "#87d7af",
357        "#87d7d7",
358        "#87d7ff",
359        "#87ff00",
360        "#87ff5f",
361        "#87ff87",
362        "#87ffaf",
363        "#87ffd7",
364        "#87ffff",
365        "#af0000",
366        "#af005f",
367        "#af0087",
368        "#af00af",
369        "#af00d7",
370        "#af00ff",
371        "#af5f00",
372        "#af5f5f",
373        "#af5f87",
374        "#af5faf",
375        "#af5fd7",
376        "#af5fff",
377        "#af8700",
378        "#af875f",
379        "#af8787",
380        "#af87af",
381        "#af87d7",
382        "#af87ff",
383        "#afaf00",
384        "#afaf5f",
385        "#afaf87",
386        "#afafaf",
387        "#afafd7",
388        "#afafff",
389        "#afd700",
390        "#afd75f",
391        "#afd787",
392        "#afd7af",
393        "#afd7d7",
394        "#afd7ff",
395        "#afff00",
396        "#afff5f",
397        "#afff87",
398        "#afffaf",
399        "#afffd7",
400        "#afffff",
401        "#d70000",
402        "#d7005f",
403        "#d70087",
404        "#d700af",
405        "#d700d7",
406        "#d700ff",
407        "#d75f00",
408        "#d75f5f",
409        "#d75f87",
410        "#d75faf",
411        "#d75fd7",
412        "#d75fff",
413        "#d78700",
414        "#d7875f",
415        "#d78787",
416        "#d787af",
417        "#d787d7",
418        "#d787ff",
419        "#d7af00",
420        "#d7af5f",
421        "#d7af87",
422        "#d7afaf",
423        "#d7afd7",
424        "#d7afff",
425        "#d7d700",
426        "#d7d75f",
427        "#d7d787",
428        "#d7d7af",
429        "#d7d7d7",
430        "#d7d7ff",
431        "#d7ff00",
432        "#d7ff5f",
433        "#d7ff87",
434        "#d7ffaf",
435        "#d7ffd7",
436        "#d7ffff",
437        "#ff0000",
438        "#ff005f",
439        "#ff0087",
440        "#ff00af",
441        "#ff00d7",
442        "#ff00ff",
443        "#ff5f00",
444        "#ff5f5f",
445        "#ff5f87",
446        "#ff5faf",
447        "#ff5fd7",
448        "#ff5fff",
449        "#ff8700",
450        "#ff875f",
451        "#ff8787",
452        "#ff87af",
453        "#ff87d7",
454        "#ff87ff",
455        "#ffaf00",
456        "#ffaf5f",
457        "#ffaf87",
458        "#ffafaf",
459        "#ffafd7",
460        "#ffafff",
461        "#ffd700",
462        "#ffd75f",
463        "#ffd787",
464        "#ffd7af",
465        "#ffd7d7",
466        "#ffd7ff",
467        "#ffff00",
468        "#ffff5f",
469        "#ffff87",
470        "#ffffaf",
471        "#ffffd7",
472        "#ffffff",
473        "#080808",
474        "#121212",
475        "#1c1c1c",
476        "#262626",
477        "#303030",
478        "#3a3a3a",
479        "#444444",
480        "#4e4e4e",
481        "#585858",
482        "#626262",
483        "#6c6c6c",
484        "#767676",
485        "#808080",
486        "#8a8a8a",
487        "#949494",
488        "#9e9e9e",
489        "#a8a8a8",
490        "#b2b2b2",
491        "#bcbcbc",
492        "#c6c6c6",
493        "#d0d0d0",
494        "#dadada",
495        "#e4e4e4",
496        "#eeeeee",
497    ]
498    if val < 0 or val >= len(arr):
499        return ""
500    else:
501        return arr[val]
502
503
504# Function to return special ANSI escapes like exit_attribute_mode
505g_special_escapes_dict = None
506
507
508def get_special_ansi_escapes():
509    global g_special_escapes_dict
510    if g_special_escapes_dict is None:
511        import curses
512
513        g_special_escapes_dict = {}
514        curses.setupterm()
515
516        # Helper function to get a value for a tparm
517        def get_tparm(key):
518            val = None
519            key = curses.tigetstr(key)
520            if key:
521                val = curses.tparm(key)
522            if val:
523                val = val.decode("utf-8")
524            return val
525
526        # Just a few for now
527        g_special_escapes_dict["exit_attribute_mode"] = get_tparm("sgr0")
528        g_special_escapes_dict["bold"] = get_tparm("bold")
529        g_special_escapes_dict["underline"] = get_tparm("smul")
530
531    return g_special_escapes_dict
532
533
534# Given a known ANSI escape sequence, convert it to HTML and append to the list
535# Returns whether we have an open <span>
536
537
538def append_html_for_ansi_escape(full_val, result, span_open):
539    # Strip off the initial \x1b[ and terminating m
540    val = full_val[2:-1]
541
542    # Helper function to close a span if it's open
543    def close_span():
544        if span_open:
545            result.append("</span>")
546
547    # term24bit foreground color
548    match = re.match(r"38;2;(\d+);(\d+);(\d+)", val)
549    if match is not None:
550        close_span()
551        # Just use the rgb values directly
552        html_color = "#%02x%02x%02x" % (
553            int(match.group(1)),
554            int(match.group(2)),
555            int(match.group(3)),
556        )
557        result.append('<span style="color: ' + html_color + '">')
558        return True  # span now open
559
560    # term256 foreground color
561    match = re.match(r"38;5;(\d+)", val)
562    if match is not None:
563        close_span()
564        html_color = html_color_for_ansi_color_index(int(match.group(1)))
565        result.append('<span style="color: ' + html_color + '">')
566        return True  # span now open
567
568    # term16 foreground color
569    if val in (str(x) for x in chain(range(90, 97), range(30, 38))):
570        close_span()
571        html_color = html_color_for_ansi_color_index(
572            int(val) - (30 if int(val) < 90 else 82)
573        )
574        result.append('<span style="color: ' + html_color + '">')
575        return True  # span now open
576
577    # Try special escapes
578    special_escapes = get_special_ansi_escapes()
579    if full_val == special_escapes["exit_attribute_mode"]:
580        close_span()
581        return False
582
583    # TODO We don't handle bold, underline, italics, dim, or reverse yet
584
585    # Do nothing on failure
586    return span_open
587
588
589def strip_ansi(val):
590    # Make a half-assed effort to strip ANSI control sequences
591    # We assume that all such sequences start with 0x1b and end with m or ctrl-o,
592    # which catches most cases
593    return re.sub("\x1b[^m]*m\x0f?", "", val)
594
595
596def ansi_prompt_line_width(val):
597    # Given an ANSI prompt, return the length of its longest line, as in the
598    # number of characters it takes up. Start by stripping off ANSI.
599    stripped_val = strip_ansi(val)
600
601    # Now count the longest line
602    return max([len(x) for x in stripped_val.split("\n")])
603
604
605def ansi_to_html(val):
606    # Split us up by ANSI escape sequences. We want to catch not only the
607    # standard color codes, but also things like sgr0. Hence this lame check.
608    # Note that Python 2.6 doesn't have a flag param to re.split, so we have to
609    # compile it first.
610    reg = re.compile(
611        """
612        (                        # Capture
613         \x1b                    # Escape
614         [^m]*                   # Zero or more non-'m's
615         m                       # Literal m terminates the sequence
616         \x0f?                   # HACK: A ctrl-o - this is how tmux' sgr0 ends
617        )                        # End capture
618        """,
619        re.VERBOSE,
620    )
621    separated = reg.split(val)
622
623    # We have to HTML escape the text and convert ANSI escapes into HTML
624    # Collect it all into this array
625    result = []
626
627    span_open = False
628
629    # Text is at even indexes, escape sequences at odd indexes
630    for i in range(len(separated)):
631        component = separated[i]
632        if i % 2 == 0:
633            # It's text, possibly empty
634            # Clean up other ANSI junk
635            result.append(escape_html(strip_ansi(component)))
636        else:
637            # It's an escape sequence. Close the previous escape.
638            span_open = append_html_for_ansi_escape(component, result, span_open)
639
640    # Close final escape
641    if span_open:
642        result.append("</span>")
643
644    # Remove empty elements
645    result = [x for x in result if x]
646
647    # Clean up empty spans, the nasty way
648    idx = len(result) - 1
649    while idx >= 1:
650        if result[idx] == "</span>" and result[idx - 1].startswith("<span"):
651            # Empty span, delete these two
652            result[idx - 1 : idx + 1] = []
653            idx = idx - 1
654        idx = idx - 1
655
656    return "".join(result)
657
658
659class FishVar:
660    """ A class that represents a variable """
661
662    def __init__(self, name, value):
663        self.name = name
664        self.value = value
665        self.universal = False
666        self.exported = False
667
668    def get_json_obj(self):
669        # Return an array(3): name, value, flags
670        flags = []
671        if self.universal:
672            flags.append("universal")
673        if self.exported:
674            flags.append("exported")
675        return {"name": self.name, "value": self.value, "Flags": ", ".join(flags)}
676
677
678class FishBinding:
679    """A class that represents keyboard binding """
680
681    def __init__(self, command, raw_binding, readable_binding, description=None):
682        self.command = command
683        self.bindings = []
684        self.description = description
685        self.add_binding(raw_binding, readable_binding)
686
687    def add_binding(self, raw_binding, readable_binding):
688        for i in self.bindings:
689            if i["readable_binding"] == readable_binding:
690                i["raw_bindings"].append(raw_binding)
691                break
692        else:
693            self.bindings.append(
694                {"readable_binding": readable_binding, "raw_bindings": [raw_binding]}
695            )
696
697    def get_json_obj(self):
698        return {
699            "command": self.command,
700            "bindings": self.bindings,
701            "description": self.description,
702        }
703
704
705class BindingParser:
706    """ Class to parse codes for bind command """
707
708    # TODO: What does snext and sprevious mean ?
709    readable_keys = {
710        "dc": "Delete",
711        "npage": "Page Up",
712        "ppage": "Page Down",
713        "sdc": "Shift Delete",
714        "shome": "Shift Home",
715        "left": "Left Arrow",
716        "right": "Right Arrow",
717        "up": "Up Arrow",
718        "down": "Down Arrow",
719        "sleft": "Shift Left",
720        "sright": "Shift Right",
721        "btab": "Shift Tab",
722    }
723
724    def set_buffer(self, buffer):
725        """ Sets code to parse """
726
727        self.buffer = buffer or b""
728        self.index = 0
729
730    def get_char(self):
731        """ Gets next character from buffer """
732        if self.index >= len(self.buffer):
733            return "\0"
734        c = self.buffer[self.index]
735        self.index += 1
736        return c
737
738    def unget_char(self):
739        """ Goes back by one character for parsing """
740
741        self.index -= 1
742
743    def end(self):
744        """ Returns true if reached end of buffer """
745
746        return self.index >= len(self.buffer)
747
748    def parse_control_sequence(self):
749        """ Parses terminal specifiec control sequences """
750
751        result = ""
752        c = self.get_char()
753
754        # \e0 is used to denote start of control sequence
755        if c == "O":
756            c = self.get_char()
757
758        # \[1\; is start of control sequence
759        if c == "1":
760            b = self.get_char()
761            c = self.get_char()
762            if b == "\\" and c == "~":
763                result += "Home"
764            elif c == ";":
765                c = self.get_char()
766
767        # 3 is Alt
768        if c == "3":
769            result += "ALT - "
770            c = self.get_char()
771
772        # \[4\~ is End
773        if c == "4":
774            b = self.get_char()
775            c = self.get_char()
776            if b == "\\" and c == "~":
777                result += "End"
778
779        # 5 is Ctrl
780        if c == "5":
781            result += "CTRL - "
782            c = self.get_char()
783
784        # 9 is Alt
785        if c == "9":
786            result += "ALT - "
787            c = self.get_char()
788
789        if c == "A":
790            result += "Up Arrow"
791        elif c == "B":
792            result += "Down Arrow"
793        elif c == "C":
794            result += "Right Arrow"
795        elif c == "D":
796            result += "Left Arrow"
797        elif c == "F":
798            result += "End"
799        elif c == "H":
800            result += "Home"
801
802        return result
803
804    def get_readable_binding(self):
805        """ Gets a readable representation of binding """
806
807        try:
808            result = BindingParser.readable_keys[self.buffer.lower()]
809        except KeyError:
810            result = self.parse_binding()
811
812        return result
813
814    def parse_binding(self):
815        readable_command = ""
816        result = ""
817        alt = ctrl = False
818
819        while not self.end():
820            c = self.get_char()
821
822            if c == "\\":
823                c = self.get_char()
824                if c == "e":
825                    d = self.get_char()
826                    if d == "O":
827                        self.unget_char()
828                        result += self.parse_control_sequence()
829                    elif d == "\\":
830                        if self.get_char() == "[":
831                            result += self.parse_control_sequence()
832                        else:
833                            self.unget_char()
834                            self.unget_char()
835                            alt = True
836                    elif d == "\0":
837                        result += "ESC"
838                    else:
839                        alt = True
840                        self.unget_char()
841                elif c == "c":
842                    ctrl = True
843                elif c == "n":
844                    result += "Enter"
845                elif c == "t":
846                    result += "Tab"
847                elif c == "b":
848                    result += "Backspace"
849                elif c.isalpha():
850                    result += "\\" + c
851                else:
852                    result += c
853            elif c == "\x7f":
854                result += "Backspace"
855            else:
856                result += c
857        if ctrl:
858            readable_command += "CTRL - "
859        if alt:
860            readable_command += "ALT - "
861
862        if result == "":
863            return "unknown-control-sequence"
864
865        return readable_command + result
866
867
868class FishConfigTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
869    """TCPServer that only accepts connections from localhost (IPv4/IPv6)."""
870
871    WHITELIST = set(["::1", "::ffff:127.0.0.1", "127.0.0.1"])
872
873    address_family = socket.AF_INET6 if socket.has_ipv6 else socket.AF_INET
874
875    def verify_request(self, request, client_address):
876        return client_address[0] in FishConfigTCPServer.WHITELIST
877
878
879class FishConfigHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
880    def write_to_wfile(self, txt):
881        self.wfile.write(txt.encode("utf-8"))
882
883    def do_get_colors(self):
884        # Looks for fish_color_*.
885        # Returns an array of lists [color_name, color_description, color_value]
886        result = []
887
888        # Make sure we return at least these
889        remaining = set(
890            [
891                "normal",
892                "error",
893                "command",
894                "end",
895                "param",
896                "comment",
897                "match",
898                "selection",
899                "search_match",
900                "operator",
901                "escape",
902                "quote",
903                "redirection",
904                "valid_path",
905                "autosuggestion",
906                "user",
907                "host",
908                "cancel",
909            ]
910        )
911
912        # Here are our color descriptions
913        descriptions = {
914            "normal": "Default text",
915            "command": "Ordinary commands",
916            "quote": "Text within quotes",
917            "redirection": "Like | and >",
918            "end": "Like ; and &",
919            "error": "Potential errors",
920            "param": "Command parameters",
921            "comment": "Comments start with #",
922            "match": "Matching parenthesis",
923            "selection": "Selected text",
924            "search_match": "History searching",
925            "history_current": "Directory history",
926            "operator": "Like * and ~",
927            "escape": "Escapes like \\n",
928            "cwd": "Current directory",
929            "cwd_root": "cwd for root user",
930            "valid_path": "Valid paths",
931            "autosuggestion": "Suggested completion",
932            "user": "Username in the prompt",
933            "host": "Hostname in the prompt",
934            "cancel": "The ^C cancel indicator",
935        }
936
937        out, err = run_fish_cmd("set -L")
938        for line in out.split("\n"):
939
940            for match in re.finditer(r"^fish_color_(\S+) ?(.*)", line):
941                color_name, color_value = [x.strip() for x in match.group(1, 2)]
942                color_desc = descriptions.get(color_name, "")
943                data = {"name": color_name, "description": color_desc}
944                data.update(parse_color(color_value))
945                result.append(data)
946                remaining.discard(color_name)
947
948        # Sort our result (by their keys)
949        result.sort(key=operator.itemgetter("name"))
950
951        # Ensure that we have all the color names we know about, so that if the
952        # user deletes one he can still set it again via the web interface
953        for color_name in remaining:
954            color_desc = descriptions.get(color_name, "")
955            result.append([color_name, color_desc, parse_color("")])
956
957        return result
958
959    def do_get_functions(self):
960        out, err = run_fish_cmd("functions")
961        out = out.strip()
962
963        # Not sure why fish sometimes returns this with newlines
964        if "\n" in out:
965            return out.split("\n")
966        else:
967            return out.strip().split(", ")
968
969    def do_get_variable_names(self, cmd):
970        " Given a command like 'set -U' return all the variable names "
971        out, err = run_fish_cmd(cmd)
972        return out.split("\n")
973
974    def do_get_variables(self):
975        out, err = run_fish_cmd("set -L")
976
977        # Put all the variables into a dictionary
978        vars = {}
979        for line in out.split("\n"):
980            comps = line.split(" ", 1)
981            if len(comps) < 2:
982                continue
983            fish_var = FishVar(comps[0], comps[1])
984            vars[fish_var.name] = fish_var
985
986        # Mark universal variables. L means don't abbreviate.
987        for name in self.do_get_variable_names("set -nUL"):
988            if name in vars:
989                vars[name].universal = True
990        # Mark exported variables. L means don't abbreviate.
991        for name in self.do_get_variable_names("set -nxL"):
992            if name in vars:
993                vars[name].exported = True
994
995        # Do not return history as a variable, it may be so large the browser hangs.
996        vars.pop("history", None)
997
998        return [
999            vars[key].get_json_obj()
1000            for key in sorted(vars.keys(), key=lambda x: x.lower())
1001        ]
1002
1003    def do_get_bindings(self):
1004        """ Get key bindings """
1005
1006        # Running __fish_config_interactive print fish greeting and
1007        # loads key bindings
1008        greeting, err = run_fish_cmd(" __fish_config_interactive")
1009
1010        # Load the key bindings and then list them with bind
1011        out, err = run_fish_cmd("__fish_config_interactive; bind")
1012
1013        # Remove fish greeting from output
1014        out = out[len(greeting) :]
1015
1016        # Put all the bindings into a list
1017        bindings = []
1018        command_to_binding = {}
1019        binding_parser = BindingParser()
1020
1021        for line in out.split("\n"):
1022            comps = line.split(" ", 2)
1023
1024            # If we don't have "bind", a sequence and a mapping,
1025            # it's not a valid binding.
1026            if len(comps) < 3:
1027                continue
1028
1029            # Store the "--preset" value for later
1030            if comps[1] == "--preset":
1031                preset = True
1032                # There's possibly a way to do this faster, but it's not important.
1033                comps = line.split(" ", 3)[1:]
1034            elif comps[1] == "--user":
1035                preset = False
1036                comps = line.split(" ", 3)[1:]
1037            # Check again if we removed the level.
1038            if len(comps) < 3:
1039                continue
1040
1041            if comps[1] == "-k":
1042                key_name, command = comps[2].split(" ", 1)
1043                binding_parser.set_buffer(key_name.capitalize())
1044            else:
1045                key_name = None
1046                command = comps[2]
1047                binding_parser.set_buffer(comps[1])
1048
1049            if command in bindings_blacklist:
1050                continue
1051
1052            readable_binding = binding_parser.get_readable_binding()
1053            if command in command_to_binding:
1054                fish_binding = command_to_binding[command]
1055                fish_binding.add_binding(line, readable_binding)
1056            else:
1057                fish_binding = FishBinding(command, line, readable_binding)
1058                bindings.append(fish_binding)
1059                command_to_binding[command] = fish_binding
1060
1061        return [binding.get_json_obj() for binding in bindings]
1062
1063    def do_get_history(self):
1064        # Use NUL to distinguish between history items.
1065        out, err = run_fish_cmd("builtin history -z")
1066        result = out.split("\0")
1067        if result:
1068            result.pop()  # trim off the trailing element
1069        return result
1070
1071    def do_get_color_for_variable(self, name):
1072        # Return the color with the given name, or the empty string if there is
1073        # none.
1074        out, err = run_fish_cmd("echo -n $" + name)
1075        return out
1076
1077    def do_set_color_for_variable(
1078        self, name, color, background_color, bold, underline, italics, dim, reverse
1079    ):
1080        "Sets a color for a fish color name, like 'autosuggestion'"
1081        if not color:
1082            color = "normal"
1083        varname = "fish_color_" + name
1084        # If the name already starts with "fish_", use it as the varname
1085        # This is needed for 'fish_pager_color' vars.
1086        if name.startswith("fish_"):
1087            varname = name
1088        # TODO: Check if the varname is allowable.
1089        command = "set -U " + varname
1090        if color:
1091            command += " " + color
1092        if background_color:
1093            command += " --background=" + background_color
1094        if bold:
1095            command += " --bold"
1096        if underline:
1097            command += " --underline"
1098        if italics:
1099            command += " --italics"
1100        if dim:
1101            command += " --dim"
1102        if reverse:
1103            command += " --reverse"
1104
1105        out, err = run_fish_cmd(command)
1106        return out
1107
1108    def do_get_function(self, func_name):
1109        out, err = run_fish_cmd("functions " + func_name + " | fish_indent --html")
1110        return out
1111
1112    def do_delete_history_item(self, history_item_text):
1113        # It's really lame that we always return success here
1114        cmd = (
1115            "builtin history delete --case-sensitive --exact -- %s; builtin history save"
1116            % escape_fish_cmd(history_item_text)
1117        )
1118        out, err = run_fish_cmd(cmd)
1119        return True
1120
1121    def do_set_prompt_function(self, prompt_func):
1122        cmd = "functions -e fish_right_prompt; " + prompt_func + "\n" + "funcsave fish_prompt && funcsave fish_right_prompt 2>/dev/null"
1123        out, err = run_fish_cmd(cmd)
1124        return len(err) == 0
1125
1126    def do_get_prompt(self, prompt_function_text, extras_dict):
1127        # Return the prompt output by the given command
1128        cmd = prompt_function_text + '\n builtin cd "' + initial_wd + '" \n false \n fish_prompt\n'
1129        prompt_demo_ansi, err = run_fish_cmd(cmd)
1130        prompt_demo_html = ansi_to_html(prompt_demo_ansi)
1131        right_demo_ansi, err = run_fish_cmd(
1132            "functions -e fish_right_prompt; " + prompt_function_text + '\n builtin cd "' + initial_wd + '" \n false \n functions -q fish_right_prompt && fish_right_prompt\n'
1133        )
1134        right_demo_html = ansi_to_html(right_demo_ansi)
1135        prompt_demo_font_size = self.font_size_for_ansi_prompt(prompt_demo_ansi + right_demo_ansi)
1136        result = {
1137            "function": prompt_function_text,
1138            "demo": prompt_demo_html,
1139            "font_size": prompt_demo_font_size,
1140            "right": right_demo_html,
1141        }
1142        if extras_dict:
1143            result.update(extras_dict)
1144        return result
1145
1146    def do_get_current_prompt(self):
1147        # Return the current prompt. We run 'false' to demonstrate how the
1148        # prompt shows the command status (#1624).
1149        prompt_func, err = run_fish_cmd("functions fish_prompt; functions fish_right_prompt")
1150        result = self.do_get_prompt(
1151            prompt_func.strip(),
1152            {"name": "Current"},
1153        )
1154        return result
1155
1156    def do_get_sample_prompt(self, text, extras_dict):
1157        # Return the prompt you get from the given text. Extras_dict is a
1158        # dictionary whose values get merged in. We run 'false' to demonstrate
1159        # how the prompt shows the command status (#1624)
1160        return self.do_get_prompt(text.strip(), extras_dict)
1161
1162    def parse_one_sample_prompt_hash(self, line, result_dict):
1163        # Allow us to skip whitespace, etc.
1164        if not line:
1165            return True
1166        if line.isspace():
1167            return True
1168
1169        # Parse a comment hash like '# name: Classic'
1170        match = re.match(r"#\s*(\w+?): (.+)", line, re.IGNORECASE)
1171        if match:
1172            key = match.group(1).strip()
1173            value = match.group(2).strip()
1174            result_dict[key] = value
1175            return True
1176        # Skip other hash comments
1177        return line.startswith("#")
1178
1179    def read_one_sample_prompt(self, path):
1180        try:
1181            with open(path, "rb") as fd:
1182                extras_dict = {}
1183                # Read one sample prompt from fd
1184                function_lines = []
1185                parsing_hashes = True
1186                unicode_lines = (line.decode("utf-8") for line in fd)
1187                for line in unicode_lines:
1188                    # Parse hashes until parse_one_sample_prompt_hash return
1189                    # False.
1190                    if parsing_hashes:
1191                        parsing_hashes = self.parse_one_sample_prompt_hash(
1192                            line, extras_dict
1193                        )
1194                    # Maybe not we're not parsing hashes, or maybe we already
1195                    # were not.
1196                    if not parsing_hashes:
1197                        function_lines.append(line)
1198                func = "".join(function_lines).strip()
1199                result = self.do_get_sample_prompt(func, extras_dict)
1200                return result
1201        except IOError:
1202            # Ignore unreadable files, etc.
1203            return None
1204
1205    def do_get_sample_prompts_list(self):
1206        paths = sorted(glob.iglob("sample_prompts/*.fish"))
1207        result = []
1208        try:
1209            pool = multiprocessing.pool.ThreadPool(processes=8)
1210
1211            # Kick off the "Current" meta-sample
1212            current_metasample_async = pool.apply_async(self.do_get_current_prompt)
1213
1214            # Read all of the prompts in sample_prompts
1215            sample_results = pool.map(self.read_one_sample_prompt, paths, 1)
1216            result.append(current_metasample_async.get())
1217            result.extend([r for r in sample_results if r])
1218        except ImportError:
1219            # If the platform doesn't support multiprocessing, we just do it one at a time.
1220            # This happens e.g. on Termux.
1221            print(
1222                "Platform doesn't support multiprocessing, running one at a time. This may take a while."
1223            )
1224            result.append(self.do_get_current_prompt())
1225            result.extend([self.read_one_sample_prompt(path) for path in paths])
1226        return result
1227
1228    def do_get_abbreviations(self):
1229        # Example abbreviation line:
1230        # abbr -a -U -- ls 'ls -a'
1231        result = []
1232        out, err = run_fish_cmd("abbr --show")
1233        for line in out.rstrip().split("\n"):
1234            if not line:
1235                continue
1236            _, abbr = line.split(" -- ", 1)
1237            word, phrase = abbr.split(" ", 1)
1238            result.append({"word": word, "phrase": phrase})
1239        return result
1240
1241    def do_remove_abbreviation(self, abbreviation):
1242        out, err = run_fish_cmd("abbr --erase %s" % abbreviation["word"])
1243        if err:
1244            return err
1245        else:
1246            return None
1247
1248    def do_save_abbreviation(self, abbreviation):
1249        out, err = run_fish_cmd(
1250            # Remove one layer of single-quotes because escape_fish_cmd adds them back.
1251            "abbr --add %s %s"
1252            % (
1253                escape_fish_cmd(abbreviation["word"].strip("'")),
1254                escape_fish_cmd(abbreviation["phrase"].strip("'")),
1255            )
1256        )
1257        if err:
1258            return err
1259        else:
1260            return None
1261
1262    def secure_startswith(self, haystack, needle):
1263        if len(haystack) < len(needle):
1264            return False
1265        bits = 0
1266        for x, y in zip(haystack, needle):
1267            bits |= ord(x) ^ ord(y)
1268        return bits == 0
1269
1270    def font_size_for_ansi_prompt(self, prompt_demo_ansi):
1271        width = ansi_prompt_line_width(prompt_demo_ansi)
1272        # Pick a font size
1273        if width >= 70:
1274            font_size = "8pt"
1275        if width >= 60:
1276            font_size = "10pt"
1277        elif width >= 50:
1278            font_size = "11pt"
1279        elif width >= 40:
1280            font_size = "13pt"
1281        elif width >= 30:
1282            font_size = "15pt"
1283        elif width >= 25:
1284            font_size = "16pt"
1285        elif width >= 20:
1286            font_size = "17pt"
1287        else:
1288            font_size = "18pt"
1289        return font_size
1290
1291    def do_GET(self):
1292        p = self.path
1293
1294        authpath = "/" + authkey
1295        if self.secure_startswith(p, authpath):
1296            p = p[len(authpath) :]
1297        else:
1298            return self.send_error(403)
1299        self.path = p
1300
1301        if p == "/colors/":
1302            output = self.do_get_colors()
1303        elif p == "/functions/":
1304            output = self.do_get_functions()
1305        elif p == "/variables/":
1306            output = self.do_get_variables()
1307        elif p == "/history/":
1308            # start = time.time()
1309            output = self.do_get_history()
1310            # end = time.time()
1311            # print "History: ", end - start
1312        elif p == "/sample_prompts/":
1313            output = self.do_get_sample_prompts_list()
1314        elif re.match(r"/color/(\w+)/", p):
1315            name = re.match(r"/color/(\w+)/", p).group(1)
1316            output = self.do_get_color_for_variable(name)
1317        elif p == "/bindings/":
1318            output = self.do_get_bindings()
1319        elif p == "/abbreviations/":
1320            output = self.do_get_abbreviations()
1321        else:
1322            return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self)
1323
1324        # Return valid output
1325        self.send_response(200)
1326        self.send_header("Content-type", "application/json")
1327        self.end_headers()
1328        self.write_to_wfile("\n")
1329
1330        # Output JSON
1331        self.write_to_wfile(json.dumps(output))
1332
1333    def do_POST(self):
1334        p = self.path
1335
1336        authpath = "/" + authkey
1337        if self.secure_startswith(p, authpath):
1338            p = p[len(authpath) :]
1339        else:
1340            return self.send_error(403)
1341        self.path = p
1342
1343        # This is cheesy, we want just the actual content-type.
1344        # In some cases it'll give us the encoding as well,
1345        # ("application/json;charset=utf-8")
1346        # but we don't currently care.
1347        ctype = self.headers["content-type"].split(";")[0]
1348
1349        if ctype == "application/x-www-form-urlencoded":
1350            length = int(self.headers["content-length"])
1351            url_str = self.rfile.read(length).decode("utf-8")
1352            postvars = parse_qs(url_str, keep_blank_values=1)
1353        elif ctype == "application/json":
1354            length = int(self.headers["content-length"])
1355            # This used to use the provided encoding, but we use utf-8
1356            # all around the place and nobody has ever complained.
1357            #
1358            # If any other encoding is received this will raise a UnicodeError,
1359            # which will throw us out of the function and should at most exit webconfig.
1360            # If that happens to anyone we expect bug reports.
1361            url_str = self.rfile.read(length).decode("utf-8")
1362            postvars = json.loads(url_str)
1363        elif ctype == "multipart/form-data":
1364            # This used to be a thing, as far as I could find there's
1365            # no use anymore, but let's keep an error around just in case.
1366            return self.send_error(500)
1367        else:
1368            postvars = {}
1369
1370        if p == "/set_color/":
1371            what = postvars.get("what")
1372            color = postvars.get("color")
1373            background_color = postvars.get("background_color")
1374            bold = postvars.get("bold")
1375            italics = postvars.get("italics")
1376            reverse = postvars.get("reverse")
1377            dim = postvars.get("dim")
1378            underline = postvars.get("underline")
1379
1380            if what:
1381                # Not sure why we get lists here?
1382                output = self.do_set_color_for_variable(
1383                    what[0],
1384                    color[0],
1385                    background_color[0],
1386                    parse_bool(bold[0]),
1387                    parse_bool(underline[0]),
1388                    parse_bool(italics[0]),
1389                    parse_bool(dim[0]),
1390                    parse_bool(reverse[0]),
1391                )
1392            else:
1393                output = "Bad request"
1394        elif p == "/get_function/":
1395            what = postvars.get("what")
1396            output = [self.do_get_function(what[0])]
1397        elif p == "/delete_history_item/":
1398            what = postvars.get("what")
1399            if self.do_delete_history_item(what[0]):
1400                output = ["OK"]
1401            else:
1402                output = ["Unable to delete history item"]
1403        elif p == "/set_prompt/":
1404            what = postvars.get("fish_prompt")
1405            if self.do_set_prompt_function(what):
1406                output = ["OK"]
1407            else:
1408                output = ["Unable to set prompt"]
1409        elif p == "/save_abbreviation/":
1410            errmsg = self.do_save_abbreviation(postvars)
1411            if errmsg:
1412                output = [errmsg]
1413            else:
1414                output = ["OK"]
1415        elif p == "/remove_abbreviation/":
1416            errmsg = self.do_remove_abbreviation(postvars)
1417            if errmsg:
1418                output = [errmsg]
1419            else:
1420                output = ["OK"]
1421        else:
1422            return self.send_error(404)
1423
1424        # Return valid output
1425        self.send_response(200)
1426        self.send_header("Content-type", "application/json")
1427        self.end_headers()
1428        self.write_to_wfile("\n")
1429
1430        # Output JSON
1431        self.write_to_wfile(json.dumps(output))
1432
1433    def log_request(self, code="-", size="-"):
1434        """ Disable request logging """
1435        pass
1436
1437    def log_error(self, format, *args):
1438        if format == "code %d, message %s" and hasattr(self, 'path'):
1439            # This appears to be a send_error() message
1440            # We want to include the path (if we have one)
1441            (code, msg) = args
1442            format = "code %d, message %s, path %s"
1443            args = (code, msg, self.path)
1444        SimpleHTTPServer.SimpleHTTPRequestHandler.log_error(self, format, *args)
1445
1446
1447redirect_template_html = """
1448<!DOCTYPE html>
1449<html>
1450 <head>
1451  <meta http-equiv="refresh" content="0;URL='%s'" />
1452 </head>
1453 <body>
1454  <p><a href="%s">Start the Fish Web config</a></p>
1455 </body>
1456</html>
1457"""
1458
1459# find fish
1460fish_bin_dir = os.environ.get("__fish_bin_dir")
1461fish_bin_path = None
1462if not fish_bin_dir:
1463    print("The $__fish_bin_dir environment variable is not set. " "Looking in $PATH...")
1464    fish_bin_path = find_executable("fish")
1465    if not fish_bin_path:
1466        print("fish could not be found. Is fish installed correctly?")
1467        sys.exit(-1)
1468    else:
1469        print("fish found at '%s'" % fish_bin_path)
1470
1471else:
1472    fish_bin_path = os.path.join(fish_bin_dir, "fish")
1473
1474if not os.access(fish_bin_path, os.X_OK):
1475    print(
1476        "fish could not be executed at path '%s'. "
1477        "Is fish installed correctly?" % fish_bin_path
1478    )
1479    sys.exit(-1)
1480FISH_BIN_PATH = fish_bin_path
1481
1482# We want to show the demo prompts in the directory from which this was invoked,
1483# so get the current working directory
1484initial_wd = os.getcwd()
1485
1486# Make sure that the working directory is the one that contains the script
1487# server file, because the document root is the working directory.
1488where = os.path.dirname(sys.argv[0])
1489os.chdir(where)
1490
1491# Generate a 16-byte random key as a hexadecimal string
1492authkey = binascii.b2a_hex(os.urandom(16)).decode("ascii")
1493
1494# Try to find a suitable port
1495PORT = 8000
1496HOST = "::" if socket.has_ipv6 else "localhost"
1497while PORT <= 9000:
1498    try:
1499        Handler = FishConfigHTTPRequestHandler
1500        httpd = FishConfigTCPServer((HOST, PORT), Handler)
1501        # Success
1502        break
1503    except socket.error:
1504        err_type, err_value = sys.exc_info()[:2]
1505        # str(err_value) handles Python3 correctly
1506        if "Address already in use" not in str(err_value):
1507            print(str(err_value))
1508            break
1509    PORT += 1
1510
1511if PORT > 9000:
1512    # Nobody say it
1513    print("Unable to find an open port between 8000 and 9000")
1514    sys.exit(-1)
1515
1516# Get any initial tab (functions, colors, etc)
1517# Just look at the first letter
1518initial_tab = ""
1519if len(sys.argv) > 1:
1520    for tab in [
1521        "functions",
1522        "prompt",
1523        "colors",
1524        "variables",
1525        "history",
1526        "bindings",
1527        "abbreviations",
1528    ]:
1529        if tab.startswith(sys.argv[1]):
1530            initial_tab = "#!/" + tab
1531            break
1532
1533url = "http://localhost:%d/%s/%s" % (PORT, authkey, initial_tab)
1534
1535# Create temporary file to hold redirect to real server. This prevents exposing
1536# the URL containing the authentication key on the command line (see
1537# CVE-2014-2914 or https://github.com/fish-shell/fish-shell/issues/1438).
1538f = tempfile.NamedTemporaryFile(prefix="web_config", suffix=".html", mode="w")
1539
1540f.write(redirect_template_html % (url, url))
1541f.flush()
1542
1543# Open temporary file as URL
1544# Use open on macOS >= 10.12.5 to work around #4035.
1545fileurl = "file://" + f.name
1546
1547esc = get_special_ansi_escapes()
1548print(
1549    "Web config started at %s%s%s"
1550    % (esc["underline"], fileurl, esc["exit_attribute_mode"])
1551)
1552print("%sHit ENTER to stop.%s" % (esc["bold"], esc["exit_attribute_mode"]))
1553
1554
1555def runThing():
1556    if isMacOS10_12_5_OrLater():
1557        subprocess.check_call(["open", fileurl])
1558    elif is_wsl():
1559        cmd_path = find_executable("cmd.exe", COMMON_WSL_CMD_PATHS)
1560        if cmd_path:
1561            subprocess.call([cmd_path, "/c", "start %s" % url])
1562        else:
1563            print("Please add the directory containing cmd.exe to your $PATH")
1564            sys.exit(-1)
1565    elif is_termux():
1566        subprocess.call(["termux-open-url", url])
1567    elif is_chromeos_garcon():
1568        webbrowser.open(url)
1569    else:
1570        webbrowser.open(fileurl)
1571
1572
1573# Some browsers still block webbrowser.open if they haven't been opened before,
1574# so we just spawn it in a thread.
1575thread = threading.Thread(target=runThing)
1576thread.start()
1577
1578# Safari will open sockets and not write to them, causing potential hangs
1579# on shutdown.
1580httpd.block_on_close = False
1581httpd.daemon_threads = True
1582
1583# Select on stdin and httpd
1584stdin_no = sys.stdin.fileno()
1585try:
1586    while True:
1587        ready_read = select.select([sys.stdin.fileno(), httpd.fileno()], [], [])
1588        if ready_read[0][0] < 1:
1589            print("Shutting down.")
1590            # Consume the newline so it doesn't get printed by the caller
1591            sys.stdin.readline()
1592            break
1593        else:
1594            httpd.handle_request()
1595except KeyboardInterrupt:
1596    print("\nShutting down.")
1597
1598# Clean up temporary file
1599f.close()
1600thread.join()
1601