__init__.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. # mypy: ignore-errors
  2. import railroad
  3. import pyparsing
  4. import typing
  5. from typing import (
  6. List,
  7. NamedTuple,
  8. Generic,
  9. TypeVar,
  10. Dict,
  11. Callable,
  12. Set,
  13. Iterable,
  14. )
  15. from jinja2 import Template
  16. from io import StringIO
  17. import inspect
  18. jinja2_template_source = """\
  19. {% if not embed %}
  20. <!DOCTYPE html>
  21. <html>
  22. <head>
  23. {% endif %}
  24. {% if not head %}
  25. <style>
  26. .railroad-heading {
  27. font-family: monospace;
  28. }
  29. </style>
  30. {% else %}
  31. {{ head | safe }}
  32. {% endif %}
  33. {% if not embed %}
  34. </head>
  35. <body>
  36. {% endif %}
  37. {{ body | safe }}
  38. {% for diagram in diagrams %}
  39. <div class="railroad-group">
  40. <h1 class="railroad-heading">{{ diagram.title }}</h1>
  41. <div class="railroad-description">{{ diagram.text }}</div>
  42. <div class="railroad-svg">
  43. {{ diagram.svg }}
  44. </div>
  45. </div>
  46. {% endfor %}
  47. {% if not embed %}
  48. </body>
  49. </html>
  50. {% endif %}
  51. """
  52. template = Template(jinja2_template_source)
  53. # Note: ideally this would be a dataclass, but we're supporting Python 3.5+ so we can't do this yet
  54. NamedDiagram = NamedTuple(
  55. "NamedDiagram",
  56. [("name", str), ("diagram", typing.Optional[railroad.DiagramItem]), ("index", int)],
  57. )
  58. """
  59. A simple structure for associating a name with a railroad diagram
  60. """
  61. T = TypeVar("T")
  62. class EachItem(railroad.Group):
  63. """
  64. Custom railroad item to compose a:
  65. - Group containing a
  66. - OneOrMore containing a
  67. - Choice of the elements in the Each
  68. with the group label indicating that all must be matched
  69. """
  70. all_label = "[ALL]"
  71. def __init__(self, *items):
  72. choice_item = railroad.Choice(len(items) - 1, *items)
  73. one_or_more_item = railroad.OneOrMore(item=choice_item)
  74. super().__init__(one_or_more_item, label=self.all_label)
  75. class AnnotatedItem(railroad.Group):
  76. """
  77. Simple subclass of Group that creates an annotation label
  78. """
  79. def __init__(self, label: str, item):
  80. super().__init__(item=item, label="[{}]".format(label) if label else label)
  81. class EditablePartial(Generic[T]):
  82. """
  83. Acts like a functools.partial, but can be edited. In other words, it represents a type that hasn't yet been
  84. constructed.
  85. """
  86. # We need this here because the railroad constructors actually transform the data, so can't be called until the
  87. # entire tree is assembled
  88. def __init__(self, func: Callable[..., T], args: list, kwargs: dict):
  89. self.func = func
  90. self.args = args
  91. self.kwargs = kwargs
  92. @classmethod
  93. def from_call(cls, func: Callable[..., T], *args, **kwargs) -> "EditablePartial[T]":
  94. """
  95. If you call this function in the same way that you would call the constructor, it will store the arguments
  96. as you expect. For example EditablePartial.from_call(Fraction, 1, 3)() == Fraction(1, 3)
  97. """
  98. return EditablePartial(func=func, args=list(args), kwargs=kwargs)
  99. @property
  100. def name(self):
  101. return self.kwargs["name"]
  102. def __call__(self) -> T:
  103. """
  104. Evaluate the partial and return the result
  105. """
  106. args = self.args.copy()
  107. kwargs = self.kwargs.copy()
  108. # This is a helpful hack to allow you to specify varargs parameters (e.g. *args) as keyword args (e.g.
  109. # args=['list', 'of', 'things'])
  110. arg_spec = inspect.getfullargspec(self.func)
  111. if arg_spec.varargs in self.kwargs:
  112. args += kwargs.pop(arg_spec.varargs)
  113. return self.func(*args, **kwargs)
  114. def railroad_to_html(diagrams: List[NamedDiagram], embed=False, **kwargs) -> str:
  115. """
  116. Given a list of NamedDiagram, produce a single HTML string that visualises those diagrams
  117. :params kwargs: kwargs to be passed in to the template
  118. """
  119. data = []
  120. for diagram in diagrams:
  121. if diagram.diagram is None:
  122. continue
  123. io = StringIO()
  124. try:
  125. css = kwargs.get('css')
  126. diagram.diagram.writeStandalone(io.write, css=css)
  127. except AttributeError:
  128. diagram.diagram.writeSvg(io.write)
  129. title = diagram.name
  130. if diagram.index == 0:
  131. title += " (root)"
  132. data.append({"title": title, "text": "", "svg": io.getvalue()})
  133. return template.render(diagrams=data, embed=embed, **kwargs)
  134. def resolve_partial(partial: "EditablePartial[T]") -> T:
  135. """
  136. Recursively resolves a collection of Partials into whatever type they are
  137. """
  138. if isinstance(partial, EditablePartial):
  139. partial.args = resolve_partial(partial.args)
  140. partial.kwargs = resolve_partial(partial.kwargs)
  141. return partial()
  142. elif isinstance(partial, list):
  143. return [resolve_partial(x) for x in partial]
  144. elif isinstance(partial, dict):
  145. return {key: resolve_partial(x) for key, x in partial.items()}
  146. else:
  147. return partial
  148. def to_railroad(
  149. element: pyparsing.ParserElement,
  150. diagram_kwargs: typing.Optional[dict] = None,
  151. vertical: int = 3,
  152. show_results_names: bool = False,
  153. show_groups: bool = False,
  154. ) -> List[NamedDiagram]:
  155. """
  156. Convert a pyparsing element tree into a list of diagrams. This is the recommended entrypoint to diagram
  157. creation if you want to access the Railroad tree before it is converted to HTML
  158. :param element: base element of the parser being diagrammed
  159. :param diagram_kwargs: kwargs to pass to the Diagram() constructor
  160. :param vertical: (optional) - int - limit at which number of alternatives should be
  161. shown vertically instead of horizontally
  162. :param show_results_names - bool to indicate whether results name annotations should be
  163. included in the diagram
  164. :param show_groups - bool to indicate whether groups should be highlighted with an unlabeled
  165. surrounding box
  166. """
  167. # Convert the whole tree underneath the root
  168. lookup = ConverterState(diagram_kwargs=diagram_kwargs or {})
  169. _to_diagram_element(
  170. element,
  171. lookup=lookup,
  172. parent=None,
  173. vertical=vertical,
  174. show_results_names=show_results_names,
  175. show_groups=show_groups,
  176. )
  177. root_id = id(element)
  178. # Convert the root if it hasn't been already
  179. if root_id in lookup:
  180. if not element.customName:
  181. lookup[root_id].name = ""
  182. lookup[root_id].mark_for_extraction(root_id, lookup, force=True)
  183. # Now that we're finished, we can convert from intermediate structures into Railroad elements
  184. diags = list(lookup.diagrams.values())
  185. if len(diags) > 1:
  186. # collapse out duplicate diags with the same name
  187. seen = set()
  188. deduped_diags = []
  189. for d in diags:
  190. # don't extract SkipTo elements, they are uninformative as subdiagrams
  191. if d.name == "...":
  192. continue
  193. if d.name is not None and d.name not in seen:
  194. seen.add(d.name)
  195. deduped_diags.append(d)
  196. resolved = [resolve_partial(partial) for partial in deduped_diags]
  197. else:
  198. # special case - if just one diagram, always display it, even if
  199. # it has no name
  200. resolved = [resolve_partial(partial) for partial in diags]
  201. return sorted(resolved, key=lambda diag: diag.index)
  202. def _should_vertical(
  203. specification: int, exprs: Iterable[pyparsing.ParserElement]
  204. ) -> bool:
  205. """
  206. Returns true if we should return a vertical list of elements
  207. """
  208. if specification is None:
  209. return False
  210. else:
  211. return len(_visible_exprs(exprs)) >= specification
  212. class ElementState:
  213. """
  214. State recorded for an individual pyparsing Element
  215. """
  216. # Note: this should be a dataclass, but we have to support Python 3.5
  217. def __init__(
  218. self,
  219. element: pyparsing.ParserElement,
  220. converted: EditablePartial,
  221. parent: EditablePartial,
  222. number: int,
  223. name: str = None,
  224. parent_index: typing.Optional[int] = None,
  225. ):
  226. #: The pyparsing element that this represents
  227. self.element: pyparsing.ParserElement = element
  228. #: The name of the element
  229. self.name: typing.Optional[str] = name
  230. #: The output Railroad element in an unconverted state
  231. self.converted: EditablePartial = converted
  232. #: The parent Railroad element, which we store so that we can extract this if it's duplicated
  233. self.parent: EditablePartial = parent
  234. #: The order in which we found this element, used for sorting diagrams if this is extracted into a diagram
  235. self.number: int = number
  236. #: The index of this inside its parent
  237. self.parent_index: typing.Optional[int] = parent_index
  238. #: If true, we should extract this out into a subdiagram
  239. self.extract: bool = False
  240. #: If true, all of this element's children have been filled out
  241. self.complete: bool = False
  242. def mark_for_extraction(
  243. self, el_id: int, state: "ConverterState", name: str = None, force: bool = False
  244. ):
  245. """
  246. Called when this instance has been seen twice, and thus should eventually be extracted into a sub-diagram
  247. :param el_id: id of the element
  248. :param state: element/diagram state tracker
  249. :param name: name to use for this element's text
  250. :param force: If true, force extraction now, regardless of the state of this. Only useful for extracting the
  251. root element when we know we're finished
  252. """
  253. self.extract = True
  254. # Set the name
  255. if not self.name:
  256. if name:
  257. # Allow forcing a custom name
  258. self.name = name
  259. elif self.element.customName:
  260. self.name = self.element.customName
  261. else:
  262. self.name = ""
  263. # Just because this is marked for extraction doesn't mean we can do it yet. We may have to wait for children
  264. # to be added
  265. # Also, if this is just a string literal etc, don't bother extracting it
  266. if force or (self.complete and _worth_extracting(self.element)):
  267. state.extract_into_diagram(el_id)
  268. class ConverterState:
  269. """
  270. Stores some state that persists between recursions into the element tree
  271. """
  272. def __init__(self, diagram_kwargs: typing.Optional[dict] = None):
  273. #: A dictionary mapping ParserElements to state relating to them
  274. self._element_diagram_states: Dict[int, ElementState] = {}
  275. #: A dictionary mapping ParserElement IDs to subdiagrams generated from them
  276. self.diagrams: Dict[int, EditablePartial[NamedDiagram]] = {}
  277. #: The index of the next unnamed element
  278. self.unnamed_index: int = 1
  279. #: The index of the next element. This is used for sorting
  280. self.index: int = 0
  281. #: Shared kwargs that are used to customize the construction of diagrams
  282. self.diagram_kwargs: dict = diagram_kwargs or {}
  283. self.extracted_diagram_names: Set[str] = set()
  284. def __setitem__(self, key: int, value: ElementState):
  285. self._element_diagram_states[key] = value
  286. def __getitem__(self, key: int) -> ElementState:
  287. return self._element_diagram_states[key]
  288. def __delitem__(self, key: int):
  289. del self._element_diagram_states[key]
  290. def __contains__(self, key: int):
  291. return key in self._element_diagram_states
  292. def generate_unnamed(self) -> int:
  293. """
  294. Generate a number used in the name of an otherwise unnamed diagram
  295. """
  296. self.unnamed_index += 1
  297. return self.unnamed_index
  298. def generate_index(self) -> int:
  299. """
  300. Generate a number used to index a diagram
  301. """
  302. self.index += 1
  303. return self.index
  304. def extract_into_diagram(self, el_id: int):
  305. """
  306. Used when we encounter the same token twice in the same tree. When this
  307. happens, we replace all instances of that token with a terminal, and
  308. create a new subdiagram for the token
  309. """
  310. position = self[el_id]
  311. # Replace the original definition of this element with a regular block
  312. if position.parent:
  313. ret = EditablePartial.from_call(railroad.NonTerminal, text=position.name)
  314. if "item" in position.parent.kwargs:
  315. position.parent.kwargs["item"] = ret
  316. elif "items" in position.parent.kwargs:
  317. position.parent.kwargs["items"][position.parent_index] = ret
  318. # If the element we're extracting is a group, skip to its content but keep the title
  319. if position.converted.func == railroad.Group:
  320. content = position.converted.kwargs["item"]
  321. else:
  322. content = position.converted
  323. self.diagrams[el_id] = EditablePartial.from_call(
  324. NamedDiagram,
  325. name=position.name,
  326. diagram=EditablePartial.from_call(
  327. railroad.Diagram, content, **self.diagram_kwargs
  328. ),
  329. index=position.number,
  330. )
  331. del self[el_id]
  332. def _worth_extracting(element: pyparsing.ParserElement) -> bool:
  333. """
  334. Returns true if this element is worth having its own sub-diagram. Simply, if any of its children
  335. themselves have children, then its complex enough to extract
  336. """
  337. children = element.recurse()
  338. return any(child.recurse() for child in children)
  339. def _apply_diagram_item_enhancements(fn):
  340. """
  341. decorator to ensure enhancements to a diagram item (such as results name annotations)
  342. get applied on return from _to_diagram_element (we do this since there are several
  343. returns in _to_diagram_element)
  344. """
  345. def _inner(
  346. element: pyparsing.ParserElement,
  347. parent: typing.Optional[EditablePartial],
  348. lookup: ConverterState = None,
  349. vertical: int = None,
  350. index: int = 0,
  351. name_hint: str = None,
  352. show_results_names: bool = False,
  353. show_groups: bool = False,
  354. ) -> typing.Optional[EditablePartial]:
  355. ret = fn(
  356. element,
  357. parent,
  358. lookup,
  359. vertical,
  360. index,
  361. name_hint,
  362. show_results_names,
  363. show_groups,
  364. )
  365. # apply annotation for results name, if present
  366. if show_results_names and ret is not None:
  367. element_results_name = element.resultsName
  368. if element_results_name:
  369. # add "*" to indicate if this is a "list all results" name
  370. element_results_name += "" if element.modalResults else "*"
  371. ret = EditablePartial.from_call(
  372. railroad.Group, item=ret, label=element_results_name
  373. )
  374. return ret
  375. return _inner
  376. def _visible_exprs(exprs: Iterable[pyparsing.ParserElement]):
  377. non_diagramming_exprs = (
  378. pyparsing.ParseElementEnhance,
  379. pyparsing.PositionToken,
  380. pyparsing.And._ErrorStop,
  381. )
  382. return [
  383. e
  384. for e in exprs
  385. if not (e.customName or e.resultsName or isinstance(e, non_diagramming_exprs))
  386. ]
  387. @_apply_diagram_item_enhancements
  388. def _to_diagram_element(
  389. element: pyparsing.ParserElement,
  390. parent: typing.Optional[EditablePartial],
  391. lookup: ConverterState = None,
  392. vertical: int = None,
  393. index: int = 0,
  394. name_hint: str = None,
  395. show_results_names: bool = False,
  396. show_groups: bool = False,
  397. ) -> typing.Optional[EditablePartial]:
  398. """
  399. Recursively converts a PyParsing Element to a railroad Element
  400. :param lookup: The shared converter state that keeps track of useful things
  401. :param index: The index of this element within the parent
  402. :param parent: The parent of this element in the output tree
  403. :param vertical: Controls at what point we make a list of elements vertical. If this is an integer (the default),
  404. it sets the threshold of the number of items before we go vertical. If True, always go vertical, if False, never
  405. do so
  406. :param name_hint: If provided, this will override the generated name
  407. :param show_results_names: bool flag indicating whether to add annotations for results names
  408. :returns: The converted version of the input element, but as a Partial that hasn't yet been constructed
  409. :param show_groups: bool flag indicating whether to show groups using bounding box
  410. """
  411. exprs = element.recurse()
  412. name = name_hint or element.customName or element.__class__.__name__
  413. # Python's id() is used to provide a unique identifier for elements
  414. el_id = id(element)
  415. element_results_name = element.resultsName
  416. # Here we basically bypass processing certain wrapper elements if they contribute nothing to the diagram
  417. if not element.customName:
  418. if isinstance(
  419. element,
  420. (
  421. # pyparsing.TokenConverter,
  422. # pyparsing.Forward,
  423. pyparsing.Located,
  424. ),
  425. ):
  426. # However, if this element has a useful custom name, and its child does not, we can pass it on to the child
  427. if exprs:
  428. if not exprs[0].customName:
  429. propagated_name = name
  430. else:
  431. propagated_name = None
  432. return _to_diagram_element(
  433. element.expr,
  434. parent=parent,
  435. lookup=lookup,
  436. vertical=vertical,
  437. index=index,
  438. name_hint=propagated_name,
  439. show_results_names=show_results_names,
  440. show_groups=show_groups,
  441. )
  442. # If the element isn't worth extracting, we always treat it as the first time we say it
  443. if _worth_extracting(element):
  444. if el_id in lookup:
  445. # If we've seen this element exactly once before, we are only just now finding out that it's a duplicate,
  446. # so we have to extract it into a new diagram.
  447. looked_up = lookup[el_id]
  448. looked_up.mark_for_extraction(el_id, lookup, name=name_hint)
  449. ret = EditablePartial.from_call(railroad.NonTerminal, text=looked_up.name)
  450. return ret
  451. elif el_id in lookup.diagrams:
  452. # If we have seen the element at least twice before, and have already extracted it into a subdiagram, we
  453. # just put in a marker element that refers to the sub-diagram
  454. ret = EditablePartial.from_call(
  455. railroad.NonTerminal, text=lookup.diagrams[el_id].kwargs["name"]
  456. )
  457. return ret
  458. # Recursively convert child elements
  459. # Here we find the most relevant Railroad element for matching pyparsing Element
  460. # We use ``items=[]`` here to hold the place for where the child elements will go once created
  461. if isinstance(element, pyparsing.And):
  462. # detect And's created with ``expr*N`` notation - for these use a OneOrMore with a repeat
  463. # (all will have the same name, and resultsName)
  464. if not exprs:
  465. return None
  466. if len(set((e.name, e.resultsName) for e in exprs)) == 1:
  467. ret = EditablePartial.from_call(
  468. railroad.OneOrMore, item="", repeat=str(len(exprs))
  469. )
  470. elif _should_vertical(vertical, exprs):
  471. ret = EditablePartial.from_call(railroad.Stack, items=[])
  472. else:
  473. ret = EditablePartial.from_call(railroad.Sequence, items=[])
  474. elif isinstance(element, (pyparsing.Or, pyparsing.MatchFirst)):
  475. if not exprs:
  476. return None
  477. if _should_vertical(vertical, exprs):
  478. ret = EditablePartial.from_call(railroad.Choice, 0, items=[])
  479. else:
  480. ret = EditablePartial.from_call(railroad.HorizontalChoice, items=[])
  481. elif isinstance(element, pyparsing.Each):
  482. if not exprs:
  483. return None
  484. ret = EditablePartial.from_call(EachItem, items=[])
  485. elif isinstance(element, pyparsing.NotAny):
  486. ret = EditablePartial.from_call(AnnotatedItem, label="NOT", item="")
  487. elif isinstance(element, pyparsing.FollowedBy):
  488. ret = EditablePartial.from_call(AnnotatedItem, label="LOOKAHEAD", item="")
  489. elif isinstance(element, pyparsing.PrecededBy):
  490. ret = EditablePartial.from_call(AnnotatedItem, label="LOOKBEHIND", item="")
  491. elif isinstance(element, pyparsing.Group):
  492. if show_groups:
  493. ret = EditablePartial.from_call(AnnotatedItem, label="", item="")
  494. else:
  495. ret = EditablePartial.from_call(railroad.Group, label="", item="")
  496. elif isinstance(element, pyparsing.TokenConverter):
  497. label = type(element).__name__.lower()
  498. if label == "tokenconverter":
  499. ret = EditablePartial.from_call(railroad.Sequence, items=[])
  500. else:
  501. ret = EditablePartial.from_call(AnnotatedItem, label=label, item="")
  502. elif isinstance(element, pyparsing.Opt):
  503. ret = EditablePartial.from_call(railroad.Optional, item="")
  504. elif isinstance(element, pyparsing.OneOrMore):
  505. ret = EditablePartial.from_call(railroad.OneOrMore, item="")
  506. elif isinstance(element, pyparsing.ZeroOrMore):
  507. ret = EditablePartial.from_call(railroad.ZeroOrMore, item="")
  508. elif isinstance(element, pyparsing.Group):
  509. ret = EditablePartial.from_call(
  510. railroad.Group, item=None, label=element_results_name
  511. )
  512. elif isinstance(element, pyparsing.Empty) and not element.customName:
  513. # Skip unnamed "Empty" elements
  514. ret = None
  515. elif isinstance(element, pyparsing.ParseElementEnhance):
  516. ret = EditablePartial.from_call(railroad.Sequence, items=[])
  517. elif len(exprs) > 0 and not element_results_name:
  518. ret = EditablePartial.from_call(railroad.Group, item="", label=name)
  519. elif len(exprs) > 0:
  520. ret = EditablePartial.from_call(railroad.Sequence, items=[])
  521. else:
  522. terminal = EditablePartial.from_call(railroad.Terminal, element.defaultName)
  523. ret = terminal
  524. if ret is None:
  525. return
  526. # Indicate this element's position in the tree so we can extract it if necessary
  527. lookup[el_id] = ElementState(
  528. element=element,
  529. converted=ret,
  530. parent=parent,
  531. parent_index=index,
  532. number=lookup.generate_index(),
  533. )
  534. if element.customName:
  535. lookup[el_id].mark_for_extraction(el_id, lookup, element.customName)
  536. i = 0
  537. for expr in exprs:
  538. # Add a placeholder index in case we have to extract the child before we even add it to the parent
  539. if "items" in ret.kwargs:
  540. ret.kwargs["items"].insert(i, None)
  541. item = _to_diagram_element(
  542. expr,
  543. parent=ret,
  544. lookup=lookup,
  545. vertical=vertical,
  546. index=i,
  547. show_results_names=show_results_names,
  548. show_groups=show_groups,
  549. )
  550. # Some elements don't need to be shown in the diagram
  551. if item is not None:
  552. if "item" in ret.kwargs:
  553. ret.kwargs["item"] = item
  554. elif "items" in ret.kwargs:
  555. # If we've already extracted the child, don't touch this index, since it's occupied by a nonterminal
  556. ret.kwargs["items"][i] = item
  557. i += 1
  558. elif "items" in ret.kwargs:
  559. # If we're supposed to skip this element, remove it from the parent
  560. del ret.kwargs["items"][i]
  561. # If all this items children are none, skip this item
  562. if ret and (
  563. ("items" in ret.kwargs and len(ret.kwargs["items"]) == 0)
  564. or ("item" in ret.kwargs and ret.kwargs["item"] is None)
  565. ):
  566. ret = EditablePartial.from_call(railroad.Terminal, name)
  567. # Mark this element as "complete", ie it has all of its children
  568. if el_id in lookup:
  569. lookup[el_id].complete = True
  570. if el_id in lookup and lookup[el_id].extract and lookup[el_id].complete:
  571. lookup.extract_into_diagram(el_id)
  572. if ret is not None:
  573. ret = EditablePartial.from_call(
  574. railroad.NonTerminal, text=lookup.diagrams[el_id].kwargs["name"]
  575. )
  576. return ret