OILS / osh / prompt.py View on Github | oilshell.org

362 lines, 210 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
112 self.parse_ctx = parse_ctx
113 self.mem = mem
114 # Cache to save syscalls / libc calls.
115 self.cache = _PromptEvaluatorCache()
116
117 # These caches should reduce memory pressure a bit. We don't want to
118 # reparse the prompt twice every time you hit enter.
119 self.tokens_cache = {} # type: Dict[str, List[Tuple[Id_t, str]]]
120 self.parse_cache = {} # type: Dict[str, CompoundWord]
121
122 def CheckCircularDeps(self):
123 # type: () -> None
124 assert self.word_ev is not None
125
126 def PromptVal(self, what):
127 # type: (str) -> str
128 """
129 _io->promptVal('$')
130 """
131 if what == 'D':
132 # TODO: wrap strftime(), time(), localtime(), etc. so users can do
133 # it themselves
134 return _ERROR_FMT % '\D{} not in promptVal()'
135 else:
136 # Could make hostname -> h alias, etc.
137 return self.PromptSubst(what)
138
139 def PromptSubst(self, ch, arg=None):
140 # type: (str, Optional[str]) -> str
141
142 if ch == '$': # So the user can tell if they're root or not.
143 r = self.cache.Get('$')
144
145 elif ch == 'u':
146 r = self.cache.Get('user')
147
148 elif ch == 'h':
149 hostname = self.cache.Get('hostname')
150 # foo.com -> foo
151 r, _ = mylib.split_once(hostname, '.')
152
153 elif ch == 'H':
154 r = self.cache.Get('hostname')
155
156 elif ch == 's':
157 r = self.lang
158
159 elif ch == 'v':
160 r = self.version_str
161
162 elif ch == 'A':
163 now = time_.time()
164 r = time_.strftime('%H:%M', time_.localtime(now))
165
166 elif ch == 'D': # \D{%H:%M} is the only one with a suffix
167 now = time_.time()
168 assert arg is not None
169 if len(arg) == 0:
170 # In bash y.tab.c uses %X when string is empty
171 # This doesn't seem to match exactly, but meh for now.
172 fmt = '%X'
173 else:
174 fmt = arg
175 r = time_.strftime(fmt, time_.localtime(now))
176
177 elif ch == 'w':
178 try:
179 # doesn't have to exist
180 home = state.MaybeString(self.mem, 'HOME')
181 # Shorten to ~/mydir
182 r = ui.PrettyDir(self.mem.pwd, home)
183 except error.Runtime as e:
184 r = _ERROR_FMT % 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_FMT % 'PWD is not a string'
193
194 else:
195 # e.g. \e \r \n \\
196 r = consts.LookupCharPrompt(ch)
197
198 # TODO: Handle more codes
199 # R(r'\\[adehHjlnrstT@AuvVwW!#$\\]', Id.PS_Subst),
200 if r is None:
201 r = _ERROR_FMT % (r'\%s is invalid or unimplemented in $PS1' %
202 ch)
203
204 return r
205
206 def _ReplaceBackslashCodes(self, tokens):
207 # type: (List[Tuple[Id_t, str]]) -> str
208 ret = [] # type: List[str]
209 non_printing = 0
210 for id_, s in tokens:
211 # BadBacklash means they should have escaped with \\. TODO: Make it an error.
212 # 'echo -e' has a similar issue.
213 if id_ in (Id.PS_Literals, Id.PS_BadBackslash):
214 ret.append(s)
215
216 elif id_ == Id.PS_Octal3:
217 i = int(s[1:], 8)
218 ret.append(chr(i % 256))
219
220 elif id_ == Id.PS_LBrace:
221 non_printing += 1
222 ret.append('\x01')
223
224 elif id_ == Id.PS_RBrace:
225 non_printing -= 1
226 if non_printing < 0: # e.g. \]\[
227 return _ERROR_FMT % _UNBALANCED_ERROR
228
229 ret.append('\x02')
230
231 elif id_ == Id.PS_Subst: # \u \h \w etc.
232 ch = s[1]
233 arg = None # type: Optional[str]
234 if ch == 'D':
235 arg = s[3:-1] # \D{%H:%M}
236 r = self.PromptSubst(ch, arg=arg)
237
238 # See comment above on bash hack for $.
239 ret.append(r.replace('$', '\\$'))
240
241 else:
242 raise AssertionError('Invalid token %r %r' % (id_, s))
243
244 # mismatched brackets, see https://github.com/oilshell/oil/pull/256
245 if non_printing != 0:
246 return _ERROR_FMT % _UNBALANCED_ERROR
247
248 return ''.join(ret)
249
250 def EvalPrompt(self, UP_val):
251 # type: (value_t) -> str
252 """Perform the two evaluations that bash does.
253
254 Used by $PS1 and ${x@P}.
255 """
256 if UP_val.tag() != value_e.Str:
257 return '' # e.g. if the user does 'unset PS1'
258
259 val = cast(value.Str, UP_val)
260
261 # Parse backslash escapes (cached)
262 tokens = self.tokens_cache.get(val.s)
263 if tokens is None:
264 tokens = match.Ps1Tokens(val.s)
265 self.tokens_cache[val.s] = tokens
266
267 # Replace values.
268 ps1_str = self._ReplaceBackslashCodes(tokens)
269
270 # Parse it like a double-quoted word (cached). TODO: This could be done on
271 # mem.SetValue(), so we get the error earlier.
272 # NOTE: This is copied from the PS4 logic in Tracer.
273 ps1_word = self.parse_cache.get(ps1_str)
274 if ps1_word is None:
275 w_parser = self.parse_ctx.MakeWordParserForPlugin(ps1_str)
276 try:
277 ps1_word = w_parser.ReadForPlugin()
278 except error.Parse as e:
279 ps1_word = word_.ErrorWord("<ERROR: Can't parse PS1: %s>" %
280 e.UserErrorString())
281 self.parse_cache[ps1_str] = ps1_word
282
283 # Evaluate, e.g. "${debian_chroot}\u" -> '\u'
284 val2 = self.word_ev.EvalForPlugin(ps1_word)
285 return val2.s
286
287 def EvalFirstPrompt(self):
288 # type: () -> str
289
290 # First try calling renderPrompt()
291 UP_func_val = self.mem.GetValue('renderPrompt')
292 if UP_func_val.tag() == value_e.Func:
293 func_val = cast(value.Func, UP_func_val)
294
295 assert self.global_io is not None
296 pos_args = [self.global_io] # type: List[value_t]
297 val = self.expr_ev.PluginCall(func_val, pos_args)
298
299 UP_val = val
300 with tagswitch(val) as case:
301 if case(value_e.Str):
302 val = cast(value.Str, UP_val)
303 return val.s
304 else:
305 msg = 'renderPrompt() should return Str, got %s' % ui.ValType(
306 val)
307 return _ERROR_FMT % msg
308
309 # Now try evaluating $PS1
310 ps1_val = self.mem.env_config.GetVal('PS1')
311 #log('ps1_val %s', ps1_val)
312 return self.EvalPrompt(ps1_val)
313
314
315PROMPT_COMMAND = 'PROMPT_COMMAND'
316
317
318class UserPlugin(object):
319 """For executing PROMPT_COMMAND and caching its parse tree.
320
321 Similar to core/dev.py:Tracer, which caches $PS4.
322 """
323
324 def __init__(self, mem, parse_ctx, cmd_ev, errfmt):
325 # type: (Mem, ParseContext, cmd_eval.CommandEvaluator, ui.ErrorFormatter) -> None
326 self.mem = mem
327 self.parse_ctx = parse_ctx
328 self.cmd_ev = cmd_ev
329 self.errfmt = errfmt
330
331 self.arena = parse_ctx.arena
332 self.parse_cache = {} # type: Dict[str, command_t]
333
334 def Run(self):
335 # type: () -> None
336 val = self.mem.GetValue(PROMPT_COMMAND)
337 if val.tag() != value_e.Str:
338 return
339
340 # PROMPT_COMMAND almost never changes, so we try to cache its parsing.
341 # This avoids memory allocations.
342 prompt_cmd = cast(value.Str, val).s
343 node = self.parse_cache.get(prompt_cmd)
344 if node is None:
345 line_reader = reader.StringLineReader(prompt_cmd, self.arena)
346 c_parser = self.parse_ctx.MakeOshParser(line_reader)
347
348 # NOTE: This is similar to CommandEvaluator.ParseTrapCode().
349 src = source.Variable(PROMPT_COMMAND, loc.Missing)
350 with alloc.ctx_SourceCode(self.arena, src):
351 try:
352 node = main_loop.ParseWholeFile(c_parser)
353 except error.Parse as e:
354 self.errfmt.PrettyPrintError(e)
355 return # don't execute
356
357 self.parse_cache[prompt_cmd] = node
358
359 # Save this so PROMPT_COMMAND can't set $?
360 with state.ctx_Registers(self.mem):
361 # Catches fatal execution error
362 self.cmd_ev.ExecuteAndCatch(node, 0)