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