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

371 lines, 212 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 in OSH or ENV.HOME in YSH
189 home_str = None # type: Optional[str]
190 home_val = self.mem.env_config.GetVal('HOME')
191 if home_val.tag() == value_e.Str:
192 home_str = cast(value.Str, home_val).s
193
194 # Shorten /home/andy/mydir -> ~/mydir
195 # Note: could also call sh_init.GetWorkingDir()?
196 r = ui.PrettyDir(self.mem.pwd, home_str)
197
198 elif ch == 'W':
199 # Note: could also call sh_init.GetWorkingDir()?
200 r = os_path.basename(self.mem.pwd)
201
202 else:
203 # e.g. \e \r \n \\
204 r = consts.LookupCharPrompt(ch)
205
206 # TODO: Handle more codes
207 # R(r'\\[adehHjlnrstT@AuvVwW!#$\\]', Id.PS_Subst),
208 if r is None:
209 r = _ERROR_FMT % (r'\%s is invalid or unimplemented in $PS1' %
210 ch)
211
212 return r
213
214 def _ReplaceBackslashCodes(self, tokens):
215 # type: (List[Tuple[Id_t, str]]) -> str
216 ret = [] # type: List[str]
217 non_printing = 0
218 for id_, s in tokens:
219 # BadBacklash means they should have escaped with \\. TODO: Make it an error.
220 # 'echo -e' has a similar issue.
221 if id_ in (Id.PS_Literals, Id.PS_BadBackslash):
222 ret.append(s)
223
224 elif id_ == Id.PS_Octal3:
225 i = int(s[1:], 8)
226 ret.append(chr(i % 256))
227
228 elif id_ == Id.PS_LBrace:
229 non_printing += 1
230 ret.append('\x01')
231
232 elif id_ == Id.PS_RBrace:
233 non_printing -= 1
234 if non_printing < 0: # e.g. \]\[
235 return _ERROR_FMT % _UNBALANCED_ERROR
236
237 ret.append('\x02')
238
239 elif id_ == Id.PS_Subst: # \u \h \w etc.
240 ch = s[1]
241 arg = None # type: Optional[str]
242 if ch == 'D':
243 arg = s[3:-1] # \D{%H:%M}
244 r = self.PromptSubst(ch, arg=arg)
245
246 # See comment above on bash hack for $.
247 ret.append(r.replace('$', '\\$'))
248
249 else:
250 raise AssertionError('Invalid token %r %r' % (id_, s))
251
252 # mismatched brackets, see https://github.com/oilshell/oil/pull/256
253 if non_printing != 0:
254 return _ERROR_FMT % _UNBALANCED_ERROR
255
256 return ''.join(ret)
257
258 def EvalPrompt(self, s):
259 # type: (str) -> str
260 """Perform the two evaluations that bash does.
261
262 Used by $PS1 and ${x@P}.
263 """
264
265 # Parse backslash escapes (cached)
266 tokens = self.tokens_cache.get(s)
267 if tokens is None:
268 tokens = match.Ps1Tokens(s)
269 self.tokens_cache[s] = tokens
270
271 # Replace values.
272 ps1_str = self._ReplaceBackslashCodes(tokens)
273
274 # Parse it like a double-quoted word (cached). TODO: This could be done on
275 # mem.SetValue(), so we get the error earlier.
276 # NOTE: This is copied from the PS4 logic in Tracer.
277 ps1_word = self.parse_cache.get(ps1_str)
278 if ps1_word is None:
279 w_parser = self.parse_ctx.MakeWordParserForPlugin(ps1_str)
280 try:
281 ps1_word = w_parser.ReadForPlugin()
282 except error.Parse as e:
283 ps1_word = word_.ErrorWord("<ERROR: Can't parse PS1: %s>" %
284 e.UserErrorString())
285 self.parse_cache[ps1_str] = ps1_word
286
287 # Evaluate, e.g. "${debian_chroot}\u" -> '\u'
288 val2 = self.word_ev.EvalForPlugin(ps1_word)
289 return val2.s
290
291 def EvalFirstPrompt(self):
292 # type: () -> str
293
294 # First try calling renderPrompt()
295 UP_func_val = self.mem.GetValue('renderPrompt')
296 if UP_func_val.tag() == value_e.Func:
297 func_val = cast(value.Func, UP_func_val)
298
299 assert self.global_io is not None
300 pos_args = [self.global_io] # type: List[value_t]
301 val = self.expr_ev.PluginCall(func_val, pos_args)
302
303 UP_val = val
304 with tagswitch(val) as case:
305 if case(value_e.Str):
306 val = cast(value.Str, UP_val)
307 return val.s
308 else:
309 msg = 'renderPrompt() should return Str, got %s' % ui.ValType(
310 val)
311 return _ERROR_FMT % msg
312
313 # Now try evaluating $PS1
314 ps1_val = self.mem.env_config.GetVal('PS1')
315 #log('ps1_val %s', ps1_val)
316 UP_ps1_val = ps1_val
317 if UP_ps1_val.tag() == value_e.Str:
318 ps1_val = cast(value.Str, UP_ps1_val)
319 return self.EvalPrompt(ps1_val.s)
320 else:
321 return '' # e.g. if the user does 'unset PS1'
322
323
324PROMPT_COMMAND = 'PROMPT_COMMAND'
325
326
327class UserPlugin(object):
328 """For executing PROMPT_COMMAND and caching its parse tree.
329
330 Similar to core/dev.py:Tracer, which caches $PS4.
331 """
332
333 def __init__(self, mem, parse_ctx, cmd_ev, errfmt):
334 # type: (Mem, ParseContext, cmd_eval.CommandEvaluator, ui.ErrorFormatter) -> None
335 self.mem = mem
336 self.parse_ctx = parse_ctx
337 self.cmd_ev = cmd_ev
338 self.errfmt = errfmt
339
340 self.arena = parse_ctx.arena
341 self.parse_cache = {} # type: Dict[str, command_t]
342
343 def Run(self):
344 # type: () -> None
345 val = self.mem.GetValue(PROMPT_COMMAND)
346 if val.tag() != value_e.Str:
347 return
348
349 # PROMPT_COMMAND almost never changes, so we try to cache its parsing.
350 # This avoids memory allocations.
351 prompt_cmd = cast(value.Str, val).s
352 node = self.parse_cache.get(prompt_cmd)
353 if node is None:
354 line_reader = reader.StringLineReader(prompt_cmd, self.arena)
355 c_parser = self.parse_ctx.MakeOshParser(line_reader)
356
357 # NOTE: This is similar to CommandEvaluator.ParseTrapCode().
358 src = source.Variable(PROMPT_COMMAND, loc.Missing)
359 with alloc.ctx_SourceCode(self.arena, src):
360 try:
361 node = main_loop.ParseWholeFile(c_parser)
362 except error.Parse as e:
363 self.errfmt.PrettyPrintError(e)
364 return # don't execute
365
366 self.parse_cache[prompt_cmd] = node
367
368 # Save this so PROMPT_COMMAND can't set $?
369 with state.ctx_Registers(self.mem):
370 # Catches fatal execution error
371 self.cmd_ev.ExecuteAndCatch(node, 0)