1 """
2 prompt.py: A LIBRARY for prompt evaluation.
3
4 User interface details should go in core/ui.py.
5 """
6 from __future__ import print_function
7
8 import time as time_
9
10 from _devbuild.gen.id_kind_asdl import Id, Id_t
11 from _devbuild.gen.runtime_asdl import value, value_e, value_t
12 from _devbuild.gen.syntax_asdl import (loc, command_t, source, CompoundWord)
13 from core import alloc
14 from core import main_loop
15 from core import error
16 from core import pyos
17 from core import state
18 from core import ui
19 from frontend import consts
20 from frontend import match
21 from frontend import reader
22 from mycpp import mylib
23 from osh import word_
24 from pylib import os_path
25
26 import libc # gethostname()
27 import posix_ as posix
28
29 from typing import Dict, List, Tuple, cast, TYPE_CHECKING
30 if TYPE_CHECKING:
31 from core.state import Mem
32 from frontend.parse_lib import ParseContext
33 from osh.cmd_eval import CommandEvaluator
34 from osh.word_eval import AbstractWordEvaluator
35
36 #
37 # Prompt Evaluation
38 #
39
40 PROMPT_ERROR = r'<Error: unbalanced \[ and \]> '
41
42
43 class _PromptEvaluatorCache(object):
44 """Cache some values we don't expect to change for the life of a
45 process."""
46
47 def __init__(self):
48 # type: () -> None
49 self.cache = {} # type: Dict[str, str]
50 self.euid = -1 # invalid value
51
52 def _GetEuid(self):
53 # type: () -> int
54 """Cached lookup."""
55 if self.euid == -1:
56 self.euid = posix.geteuid()
57 return self.euid
58
59 def Get(self, name):
60 # type: (str) -> str
61 if name in self.cache:
62 return self.cache[name]
63
64 if name == '$': # \$
65 value = '#' if self._GetEuid() == 0 else '$'
66 elif name == 'hostname': # for \h and \H
67 value = libc.gethostname()
68 elif name == 'user': # for \u
69 value = pyos.GetUserName(
70 self._GetEuid()) # recursive call for caching
71 else:
72 raise AssertionError(name)
73
74 self.cache[name] = value
75 return value
76
77
78 class Evaluator(object):
79 """Evaluate the prompt mini-language.
80
81 bash has a very silly algorithm:
82 1. replace backslash codes, except any $ in those values get quoted into \$.
83 2. Parse the word as if it's in a double quoted context, and then evaluate
84 the word.
85
86 Haven't done this from POSIX: POSIX:
87 http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
88
89 The shell shall replace each instance of the character '!' in PS1 with the
90 history file number of the next command to be typed. Escaping the '!' with
91 another '!' (that is, "!!" ) shall place the literal character '!' in the
92 prompt.
93 """
94
95 def __init__(self, lang, version_str, parse_ctx, mem):
96 # type: (str, str, ParseContext, Mem) -> None
97 self.word_ev = None # type: AbstractWordEvaluator
98
99 assert lang in ('osh', 'ysh'), lang
100 self.lang = lang
101 self.version_str = version_str
102 self.parse_ctx = parse_ctx
103 self.mem = mem
104 self.cache = _PromptEvaluatorCache(
105 ) # Cache to save syscalls / libc calls.
106
107 # These caches should reduce memory pressure a bit. We don't want to
108 # reparse the prompt twice every time you hit enter.
109 self.tokens_cache = {} # type: Dict[str, List[Tuple[Id_t, str]]]
110 self.parse_cache = {} # type: Dict[str, CompoundWord]
111
112 def CheckCircularDeps(self):
113 # type: () -> None
114 assert self.word_ev is not None
115
116 def _ReplaceBackslashCodes(self, tokens):
117 # type: (List[Tuple[Id_t, str]]) -> str
118 ret = [] # type: List[str]
119 non_printing = 0
120 for id_, s in tokens:
121 # BadBacklash means they should have escaped with \\. TODO: Make it an error.
122 # 'echo -e' has a similar issue.
123 if id_ in (Id.PS_Literals, Id.PS_BadBackslash):
124 ret.append(s)
125
126 elif id_ == Id.PS_Octal3:
127 i = int(s[1:], 8)
128 ret.append(chr(i % 256))
129
130 elif id_ == Id.PS_LBrace:
131 non_printing += 1
132 ret.append('\x01')
133
134 elif id_ == Id.PS_RBrace:
135 non_printing -= 1
136 if non_printing < 0: # e.g. \]\[
137 return PROMPT_ERROR
138
139 ret.append('\x02')
140
141 elif id_ == Id.PS_Subst: # \u \h \w etc.
142 ch = s[1]
143 if ch == '$': # So the user can tell if they're root or not.
144 r = self.cache.Get('$')
145
146 elif ch == 'u':
147 r = self.cache.Get('user')
148
149 elif ch == 'h':
150 hostname = self.cache.Get('hostname')
151 # foo.com -> foo
152 r, _ = mylib.split_once(hostname, '.')
153
154 elif ch == 'H':
155 r = self.cache.Get('hostname')
156
157 elif ch == 's':
158 r = self.lang
159
160 elif ch == 'v':
161 r = self.version_str
162
163 elif ch == 'A':
164 now = time_.time()
165 r = time_.strftime('%H:%M', time_.localtime(now))
166
167 elif ch == 'D': # \D{%H:%M} is the only one with a suffix
168 now = time_.time()
169 fmt = s[3:-1] # \D{%H:%M}
170 if len(fmt) == 0:
171 # In bash y.tab.c uses %X when string is empty
172 # This doesn't seem to match exactly, but meh for now.
173 fmt = '%X'
174 r = time_.strftime(fmt, time_.localtime(now))
175
176 elif ch == 'w':
177 try:
178 pwd = state.GetString(self.mem, 'PWD')
179 home = state.MaybeString(
180 self.mem, 'HOME') # doesn't have to exist
181 # Shorten to ~/mydir
182 r = ui.PrettyDir(pwd, home)
183 except error.Runtime as e:
184 r = '<Error: %s>' % e.UserErrorString()
185
186 elif ch == 'W':
187 val = self.mem.GetValue('PWD')
188 if val.tag() == value_e.Str:
189 str_val = cast(value.Str, val)
190 r = os_path.basename(str_val.s)
191 else:
192 r = '<Error: PWD is not a string> '
193
194 else:
195 r = consts.LookupCharPrompt(ch)
196
197 # TODO: Handle more codes
198 # R(r'\\[adehHjlnrstT@AuvVwW!#$\\]', Id.PS_Subst),
199 if r is None:
200 r = r'<Error: \%s not implemented in $PS1> ' % ch
201
202 # See comment above on bash hack for $.
203 ret.append(r.replace('$', '\\$'))
204
205 else:
206 raise AssertionError('Invalid token %r %r' % (id_, s))
207
208 # mismatched brackets, see https://github.com/oilshell/oil/pull/256
209 if non_printing != 0:
210 return PROMPT_ERROR
211
212 return ''.join(ret)
213
214 def EvalPrompt(self, UP_val):
215 # type: (value_t) -> str
216 """Perform the two evaluations that bash does.
217
218 Used by $PS1 and ${x@P}.
219 """
220 if UP_val.tag() != value_e.Str:
221 return '' # e.g. if the user does 'unset PS1'
222
223 val = cast(value.Str, UP_val)
224
225 # Parse backslash escapes (cached)
226 tokens = self.tokens_cache.get(val.s)
227 if tokens is None:
228 tokens = match.Ps1Tokens(val.s)
229 self.tokens_cache[val.s] = tokens
230
231 # Replace values.
232 ps1_str = self._ReplaceBackslashCodes(tokens)
233
234 # Parse it like a double-quoted word (cached). TODO: This could be done on
235 # mem.SetValue(), so we get the error earlier.
236 # NOTE: This is copied from the PS4 logic in Tracer.
237 ps1_word = self.parse_cache.get(ps1_str)
238 if ps1_word is None:
239 w_parser = self.parse_ctx.MakeWordParserForPlugin(ps1_str)
240 try:
241 ps1_word = w_parser.ReadForPlugin()
242 except error.Parse as e:
243 ps1_word = word_.ErrorWord("<ERROR: Can't parse PS1: %s>" %
244 e.UserErrorString())
245 self.parse_cache[ps1_str] = ps1_word
246
247 # Evaluate, e.g. "${debian_chroot}\u" -> '\u'
248 val2 = self.word_ev.EvalForPlugin(ps1_word)
249 return val2.s
250
251 def EvalFirstPrompt(self):
252 # type: () -> str
253 if self.lang == 'osh':
254 val = self.mem.GetValue('PS1')
255 return self.EvalPrompt(val)
256 else:
257 # TODO: If the lang is YSH, we should use a better prompt language than
258 # $PS1!!!
259 return self.lang + '$ '
260
261
262 PROMPT_COMMAND = 'PROMPT_COMMAND'
263
264
265 class UserPlugin(object):
266 """For executing PROMPT_COMMAND and caching its parse tree.
267
268 Similar to core/dev.py:Tracer, which caches $PS4.
269 """
270
271 def __init__(self, mem, parse_ctx, cmd_ev, errfmt):
272 # type: (Mem, ParseContext, CommandEvaluator, ui.ErrorFormatter) -> None
273 self.mem = mem
274 self.parse_ctx = parse_ctx
275 self.cmd_ev = cmd_ev
276 self.errfmt = errfmt
277
278 self.arena = parse_ctx.arena
279 self.parse_cache = {} # type: Dict[str, command_t]
280
281 def Run(self):
282 # type: () -> None
283 val = self.mem.GetValue(PROMPT_COMMAND)
284 if val.tag() != value_e.Str:
285 return
286
287 # PROMPT_COMMAND almost never changes, so we try to cache its parsing.
288 # This avoids memory allocations.
289 prompt_cmd = cast(value.Str, val).s
290 node = self.parse_cache.get(prompt_cmd)
291 if node is None:
292 line_reader = reader.StringLineReader(prompt_cmd, self.arena)
293 c_parser = self.parse_ctx.MakeOshParser(line_reader)
294
295 # NOTE: This is similar to CommandEvaluator.ParseTrapCode().
296 src = source.Variable(PROMPT_COMMAND, loc.Missing)
297 with alloc.ctx_SourceCode(self.arena, src):
298 try:
299 node = main_loop.ParseWholeFile(c_parser)
300 except error.Parse as e:
301 self.errfmt.PrettyPrintError(e)
302 return # don't execute
303
304 self.parse_cache[prompt_cmd] = node
305
306 # Save this so PROMPT_COMMAND can't set $?
307 with state.ctx_Registers(self.mem):
308 # Catches fatal execution error
309 self.cmd_ev.ExecuteAndCatch(node)