abi_parser.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. #!/usr/bin/env python3
  2. # pylint: disable=R0902,R0903,R0911,R0912,R0913,R0914,R0915,R0917,C0302
  3. # Copyright(c) 2025: Mauro Carvalho Chehab <mchehab@kernel.org>.
  4. # SPDX-License-Identifier: GPL-2.0
  5. """
  6. Parse ABI documentation and produce results from it.
  7. """
  8. from argparse import Namespace
  9. import logging
  10. import os
  11. import re
  12. from pprint import pformat
  13. from random import randrange, seed
  14. # Import Python modules
  15. from abi.helpers import AbiDebug, ABI_DIR
  16. class AbiParser:
  17. """Main class to parse ABI files."""
  18. #: Valid tags at Documentation/ABI.
  19. TAGS = r"(what|where|date|kernelversion|contact|description|users)"
  20. #: ABI elements that will auto-generate cross-references.
  21. XREF = r"(?:^|\s|\()(\/(?:sys|config|proc|dev|kvd)\/[^,.:;\)\s]+)(?:[,.:;\)\s]|\Z)"
  22. def __init__(self, directory, logger=None,
  23. enable_lineno=False, show_warnings=True, debug=0):
  24. """Stores arguments for the class and initialize class vars."""
  25. self.directory = directory
  26. self.enable_lineno = enable_lineno
  27. self.show_warnings = show_warnings
  28. self.debug = debug
  29. if not logger:
  30. self.log = logging.getLogger("get_abi")
  31. else:
  32. self.log = logger
  33. self.data = {}
  34. self.what_symbols = {}
  35. self.file_refs = {}
  36. self.what_refs = {}
  37. # Ignore files that contain such suffixes
  38. self.ignore_suffixes = (".rej", ".org", ".orig", ".bak", "~")
  39. # Regular expressions used on parser
  40. self.re_abi_dir = re.compile(r"(.*)" + ABI_DIR)
  41. self.re_tag = re.compile(r"(\S+)(:\s*)(.*)", re.I)
  42. self.re_valid = re.compile(self.TAGS)
  43. self.re_start_spc = re.compile(r"(\s*)(\S.*)")
  44. self.re_whitespace = re.compile(r"^\s+")
  45. # Regular used on print
  46. self.re_what = re.compile(r"(\/?(?:[\w\-]+\/?){1,2})")
  47. self.re_escape = re.compile(r"([\.\x01-\x08\x0e-\x1f\x21-\x2f\x3a-\x40\x7b-\xff])")
  48. self.re_unprintable = re.compile(r"([\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xff]+)")
  49. self.re_title_mark = re.compile(r"\n[\-\*\=\^\~]+\n")
  50. self.re_doc = re.compile(r"Documentation/(?!devicetree)(\S+)\.rst")
  51. self.re_abi = re.compile(r"(Documentation/ABI/)([\w\/\-]+)")
  52. self.re_xref_node = re.compile(self.XREF)
  53. def warn(self, fdata, msg, extra=None):
  54. """Displays a parse error if warning is enabled."""
  55. if not self.show_warnings:
  56. return
  57. msg = f"{fdata.fname}:{fdata.ln}: {msg}"
  58. if extra:
  59. msg += "\n\t\t" + extra
  60. self.log.warning(msg)
  61. def add_symbol(self, what, fname, ln=None, xref=None):
  62. """Create a reference table describing where each 'what' is located."""
  63. if what not in self.what_symbols:
  64. self.what_symbols[what] = {"file": {}}
  65. if fname not in self.what_symbols[what]["file"]:
  66. self.what_symbols[what]["file"][fname] = []
  67. if ln and ln not in self.what_symbols[what]["file"][fname]:
  68. self.what_symbols[what]["file"][fname].append(ln)
  69. if xref:
  70. self.what_symbols[what]["xref"] = xref
  71. def _parse_line(self, fdata, line):
  72. """Parse a single line of an ABI file."""
  73. new_what = False
  74. new_tag = False
  75. content = None
  76. match = self.re_tag.match(line)
  77. if match:
  78. new = match.group(1).lower()
  79. sep = match.group(2)
  80. content = match.group(3)
  81. match = self.re_valid.search(new)
  82. if match:
  83. new_tag = match.group(1)
  84. else:
  85. if fdata.tag == "description":
  86. # New "tag" is actually part of description.
  87. # Don't consider it a tag
  88. new_tag = False
  89. elif fdata.tag != "":
  90. self.warn(fdata, f"tag '{fdata.tag}' is invalid", line)
  91. if new_tag:
  92. # "where" is Invalid, but was a common mistake. Warn if found
  93. if new_tag == "where":
  94. self.warn(fdata, "tag 'Where' is invalid. Should be 'What:' instead")
  95. new_tag = "what"
  96. if new_tag == "what":
  97. fdata.space = None
  98. if content not in self.what_symbols:
  99. self.add_symbol(what=content, fname=fdata.fname, ln=fdata.ln)
  100. if fdata.tag == "what":
  101. fdata.what.append(content.strip("\n"))
  102. else:
  103. if fdata.key:
  104. if "description" not in self.data.get(fdata.key, {}):
  105. self.warn(fdata, f"{fdata.key} doesn't have a description")
  106. for w in fdata.what:
  107. self.add_symbol(what=w, fname=fdata.fname,
  108. ln=fdata.what_ln, xref=fdata.key)
  109. fdata.label = content
  110. new_what = True
  111. key = "abi_" + content.lower()
  112. fdata.key = self.re_unprintable.sub("_", key).strip("_")
  113. # Avoid duplicated keys but using a defined seed, to make
  114. # the namespace identical if there aren't changes at the
  115. # ABI symbols
  116. seed(42)
  117. while fdata.key in self.data:
  118. char = randrange(0, 51) + ord("A")
  119. if char > ord("Z"):
  120. char += ord("a") - ord("Z") - 1
  121. fdata.key += chr(char)
  122. if fdata.key and fdata.key not in self.data:
  123. self.data[fdata.key] = {
  124. "what": [content],
  125. "file": [fdata.file_ref],
  126. "path": fdata.ftype,
  127. "line_no": fdata.ln,
  128. }
  129. fdata.what = self.data[fdata.key]["what"]
  130. self.what_refs[content] = fdata.key
  131. fdata.tag = new_tag
  132. fdata.what_ln = fdata.ln
  133. if fdata.nametag["what"]:
  134. t = (content, fdata.key)
  135. if t not in fdata.nametag["symbols"]:
  136. fdata.nametag["symbols"].append(t)
  137. return
  138. if fdata.tag and new_tag:
  139. fdata.tag = new_tag
  140. if new_what:
  141. fdata.label = ""
  142. if "description" in self.data[fdata.key]:
  143. self.data[fdata.key]["description"] += "\n\n"
  144. if fdata.file_ref not in self.data[fdata.key]["file"]:
  145. self.data[fdata.key]["file"].append(fdata.file_ref)
  146. if self.debug == AbiDebug.WHAT_PARSING:
  147. self.log.debug("what: %s", fdata.what)
  148. if not fdata.what:
  149. self.warn(fdata, "'What:' should come first:", line)
  150. return
  151. if new_tag == "description":
  152. fdata.space = None
  153. if content:
  154. sep = sep.replace(":", " ")
  155. c = " " * len(new_tag) + sep + content
  156. c = c.expandtabs()
  157. match = self.re_start_spc.match(c)
  158. if match:
  159. # Preserve initial spaces for the first line
  160. fdata.space = match.group(1)
  161. content = match.group(2) + "\n"
  162. self.data[fdata.key][fdata.tag] = content
  163. return
  164. # Store any contents before tags at the database
  165. if not fdata.tag and "what" in fdata.nametag:
  166. fdata.nametag["description"] += line
  167. return
  168. if fdata.tag == "description":
  169. content = line.expandtabs()
  170. if self.re_whitespace.sub("", content) == "":
  171. self.data[fdata.key][fdata.tag] += "\n"
  172. return
  173. if fdata.space is None:
  174. match = self.re_start_spc.match(content)
  175. if match:
  176. # Preserve initial spaces for the first line
  177. fdata.space = match.group(1)
  178. content = match.group(2) + "\n"
  179. else:
  180. if content.startswith(fdata.space):
  181. content = content[len(fdata.space):]
  182. else:
  183. fdata.space = ""
  184. if fdata.tag == "what":
  185. w = content.strip("\n")
  186. if w:
  187. self.data[fdata.key][fdata.tag].append(w)
  188. else:
  189. self.data[fdata.key][fdata.tag] += content
  190. return
  191. content = line.strip()
  192. if fdata.tag:
  193. if fdata.tag == "what":
  194. w = content.strip("\n")
  195. if w:
  196. self.data[fdata.key][fdata.tag].append(w)
  197. else:
  198. self.data[fdata.key][fdata.tag] += "\n" + content.rstrip("\n")
  199. return
  200. # Everything else is error
  201. if content:
  202. self.warn(fdata, "Unexpected content", line)
  203. def parse_readme(self, nametag, fname):
  204. """Parse ABI README file."""
  205. nametag["what"] = ["Introduction"]
  206. nametag["path"] = "README"
  207. with open(fname, "r", encoding="utf8", errors="backslashreplace") as fp:
  208. for line in fp:
  209. match = self.re_tag.match(line)
  210. if match:
  211. new = match.group(1).lower()
  212. match = self.re_valid.search(new)
  213. if match:
  214. nametag["description"] += "\n:" + line
  215. continue
  216. nametag["description"] += line
  217. def parse_file(self, fname, path, basename):
  218. """Parse a single file."""
  219. ref = f"abi_file_{path}_{basename}"
  220. ref = self.re_unprintable.sub("_", ref).strip("_")
  221. # Store per-file state into a namespace variable. This will be used
  222. # by the per-line parser state machine and by the warning function.
  223. fdata = Namespace
  224. fdata.fname = fname
  225. fdata.name = basename
  226. pos = fname.find(ABI_DIR)
  227. if pos > 0:
  228. f = fname[pos:]
  229. else:
  230. f = fname
  231. fdata.file_ref = (f, ref)
  232. self.file_refs[f] = ref
  233. fdata.ln = 0
  234. fdata.what_ln = 0
  235. fdata.tag = ""
  236. fdata.label = ""
  237. fdata.what = []
  238. fdata.key = None
  239. fdata.xrefs = None
  240. fdata.space = None
  241. fdata.ftype = path.split("/")[0]
  242. fdata.nametag = {}
  243. fdata.nametag["what"] = [f"ABI file {path}/{basename}"]
  244. fdata.nametag["type"] = "File"
  245. fdata.nametag["path"] = fdata.ftype
  246. fdata.nametag["file"] = [fdata.file_ref]
  247. fdata.nametag["line_no"] = 1
  248. fdata.nametag["description"] = ""
  249. fdata.nametag["symbols"] = []
  250. self.data[ref] = fdata.nametag
  251. if self.debug & AbiDebug.WHAT_OPEN:
  252. self.log.debug("Opening file %s", fname)
  253. if basename == "README":
  254. self.parse_readme(fdata.nametag, fname)
  255. return
  256. with open(fname, "r", encoding="utf8", errors="backslashreplace") as fp:
  257. for line in fp:
  258. fdata.ln += 1
  259. self._parse_line(fdata, line)
  260. if "description" in fdata.nametag:
  261. fdata.nametag["description"] = fdata.nametag["description"].lstrip("\n")
  262. if fdata.key:
  263. if "description" not in self.data.get(fdata.key, {}):
  264. self.warn(fdata, f"{fdata.key} doesn't have a description")
  265. for w in fdata.what:
  266. self.add_symbol(what=w, fname=fname, xref=fdata.key)
  267. def _parse_abi(self, root=None):
  268. """Internal function to parse documentation ABI recursively."""
  269. if not root:
  270. root = self.directory
  271. with os.scandir(root) as obj:
  272. for entry in obj:
  273. name = os.path.join(root, entry.name)
  274. if entry.is_dir():
  275. self._parse_abi(name)
  276. continue
  277. if not entry.is_file():
  278. continue
  279. basename = os.path.basename(name)
  280. if basename.startswith("."):
  281. continue
  282. if basename.endswith(self.ignore_suffixes):
  283. continue
  284. path = self.re_abi_dir.sub("", os.path.dirname(name))
  285. self.parse_file(name, path, basename)
  286. def parse_abi(self, root=None):
  287. """Parse documentation ABI."""
  288. self._parse_abi(root)
  289. if self.debug & AbiDebug.DUMP_ABI_STRUCTS:
  290. self.log.debug(pformat(self.data))
  291. def desc_txt(self, desc):
  292. """Print description as found inside ABI files."""
  293. desc = desc.strip(" \t\n")
  294. return desc + "\n\n"
  295. def xref(self, fname):
  296. """
  297. Converts a Documentation/ABI + basename into a ReST cross-reference.
  298. """
  299. xref = self.file_refs.get(fname)
  300. if not xref:
  301. return None
  302. else:
  303. return xref
  304. def desc_rst(self, desc):
  305. """Enrich ReST output by creating cross-references."""
  306. # Remove title markups from the description
  307. # Having titles inside ABI files will only work if extra
  308. # care would be taken in order to strictly follow the same
  309. # level order for each markup.
  310. desc = self.re_title_mark.sub("\n\n", "\n" + desc)
  311. desc = desc.rstrip(" \t\n").lstrip("\n")
  312. # Python's regex performance for non-compiled expressions is a lot
  313. # than Perl, as Perl automatically caches them at their
  314. # first usage. Here, we'll need to do the same, as otherwise the
  315. # performance penalty is be high
  316. new_desc = ""
  317. for d in desc.split("\n"):
  318. if d == "":
  319. new_desc += "\n"
  320. continue
  321. # Use cross-references for doc files where needed
  322. d = self.re_doc.sub(r":doc:`/\1`", d)
  323. # Use cross-references for ABI generated docs where needed
  324. matches = self.re_abi.findall(d)
  325. for m in matches:
  326. abi = m[0] + m[1]
  327. xref = self.file_refs.get(abi)
  328. if not xref:
  329. # This may happen if ABI is on a separate directory,
  330. # like parsing ABI testing and symbol is at stable.
  331. # The proper solution is to move this part of the code
  332. # for it to be inside sphinx/kernel_abi.py
  333. self.log.info("Didn't find ABI reference for '%s'", abi)
  334. else:
  335. new = self.re_escape.sub(r"\\\1", m[1])
  336. d = re.sub(fr"\b{abi}\b", f":ref:`{new} <{xref}>`", d)
  337. # Seek for cross reference symbols like /sys/...
  338. # Need to be careful to avoid doing it on a code block
  339. if d[0] not in [" ", "\t"]:
  340. matches = self.re_xref_node.findall(d)
  341. for m in matches:
  342. # Finding ABI here is more complex due to wildcards
  343. xref = self.what_refs.get(m)
  344. if xref:
  345. new = self.re_escape.sub(r"\\\1", m)
  346. d = re.sub(fr"\b{m}\b", f":ref:`{new} <{xref}>`", d)
  347. new_desc += d + "\n"
  348. return new_desc + "\n\n"
  349. def doc(self, output_in_txt=False, show_symbols=True, show_file=True,
  350. filter_path=None):
  351. """Print ABI at stdout."""
  352. part = None
  353. for key, v in sorted(self.data.items(),
  354. key=lambda x: (x[1].get("type", ""),
  355. x[1].get("what"))):
  356. wtype = v.get("type", "Symbol")
  357. file_ref = v.get("file")
  358. names = v.get("what", [""])
  359. if wtype == "File":
  360. if not show_file:
  361. continue
  362. else:
  363. if not show_symbols:
  364. continue
  365. if filter_path:
  366. if v.get("path") != filter_path:
  367. continue
  368. msg = ""
  369. if wtype != "File":
  370. cur_part = names[0]
  371. if cur_part.find("/") >= 0:
  372. match = self.re_what.match(cur_part)
  373. if match:
  374. symbol = match.group(1).rstrip("/")
  375. cur_part = "Symbols under " + symbol
  376. if cur_part and cur_part != part:
  377. part = cur_part
  378. msg += part + "\n"+ "-" * len(part) +"\n\n"
  379. msg += f".. _{key}:\n\n"
  380. max_len = 0
  381. for i in range(0, len(names)): # pylint: disable=C0200
  382. names[i] = "**" + self.re_escape.sub(r"\\\1", names[i]) + "**"
  383. max_len = max(max_len, len(names[i]))
  384. msg += "+-" + "-" * max_len + "-+\n"
  385. for name in names:
  386. msg += f"| {name}" + " " * (max_len - len(name)) + " |\n"
  387. msg += "+-" + "-" * max_len + "-+\n"
  388. msg += "\n"
  389. for ref in file_ref:
  390. if wtype == "File":
  391. msg += f".. _{ref[1]}:\n\n"
  392. else:
  393. base = os.path.basename(ref[0])
  394. msg += f"Defined on file :ref:`{base} <{ref[1]}>`\n\n"
  395. if wtype == "File":
  396. msg += names[0] +"\n" + "-" * len(names[0]) +"\n\n"
  397. desc = v.get("description")
  398. if not desc and wtype != "File":
  399. msg += f"DESCRIPTION MISSING for {names[0]}\n\n"
  400. if desc:
  401. if output_in_txt:
  402. msg += self.desc_txt(desc)
  403. else:
  404. msg += self.desc_rst(desc)
  405. symbols = v.get("symbols")
  406. if symbols:
  407. msg += "Has the following ABI:\n\n"
  408. for w, label in symbols:
  409. # Escape special chars from content
  410. content = self.re_escape.sub(r"\\\1", w)
  411. msg += f"- :ref:`{content} <{label}>`\n\n"
  412. users = v.get("users")
  413. if users and users.strip(" \t\n"):
  414. users = users.strip("\n").replace('\n', '\n\t')
  415. msg += f"Users:\n\t{users}\n\n"
  416. ln = v.get("line_no", 1)
  417. yield (msg, file_ref[0][0], ln)
  418. def check_issues(self):
  419. """Warn about duplicated ABI entries."""
  420. for what, v in self.what_symbols.items():
  421. files = v.get("file")
  422. if not files:
  423. # Should never happen if the parser works properly
  424. self.log.warning("%s doesn't have a file associated", what)
  425. continue
  426. if len(files) == 1:
  427. continue
  428. f = []
  429. for fname, lines in sorted(files.items()):
  430. if not lines:
  431. f.append(f"{fname}")
  432. elif len(lines) == 1:
  433. f.append(f"{fname}:{lines[0]}")
  434. else:
  435. m = fname + "lines "
  436. m += ", ".join(str(x) for x in lines)
  437. f.append(m)
  438. self.log.warning("%s is defined %d times: %s", what, len(f), "; ".join(f))
  439. def search_symbols(self, expr):
  440. """ Searches for ABI symbols."""
  441. regex = re.compile(expr, re.I)
  442. found_keys = 0
  443. for t in sorted(self.data.items(), key=lambda x: [0]):
  444. v = t[1]
  445. wtype = v.get("type", "")
  446. if wtype == "File":
  447. continue
  448. for what in v.get("what", [""]):
  449. if regex.search(what):
  450. found_keys += 1
  451. kernelversion = v.get("kernelversion", "").strip(" \t\n")
  452. date = v.get("date", "").strip(" \t\n")
  453. contact = v.get("contact", "").strip(" \t\n")
  454. users = v.get("users", "").strip(" \t\n")
  455. desc = v.get("description", "").strip(" \t\n")
  456. files = []
  457. for f in v.get("file", ()):
  458. files.append(f[0])
  459. what = str(found_keys) + ". " + what
  460. title_tag = "-" * len(what)
  461. print(f"\n{what}\n{title_tag}\n")
  462. if kernelversion:
  463. print(f"Kernel version:\t\t{kernelversion}")
  464. if date:
  465. print(f"Date:\t\t\t{date}")
  466. if contact:
  467. print(f"Contact:\t\t{contact}")
  468. if users:
  469. print(f"Users:\t\t\t{users}")
  470. print("Defined on file(s):\t" + ", ".join(files))
  471. if desc:
  472. desc = desc.strip("\n")
  473. print(f"\n{desc}\n")
  474. if not found_keys:
  475. print(f"Regular expression /{expr}/ not found.")