OILS / osh / prompt.py View on Github | oils.pub

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