| # SPDX-License-Identifier: Apache-2.0 | |
| # Copyright 2021 The Meson development team | |
| from .generatorbase import GeneratorBase | |
| import re | |
| import json | |
| from .model import ( | |
| ReferenceManual, | |
| Function, | |
| Method, | |
| Object, | |
| ObjectType, | |
| Type, | |
| DataTypeInfo, | |
| ArgBase, | |
| PosArg, | |
| VarArgs, | |
| Kwarg, | |
| ) | |
| from pathlib import Path | |
| from textwrap import dedent | |
| import typing as T | |
| from mesonbuild import mlog | |
| PlaceholderTypes = T.Union[None, str, bool] | |
| FunctionDictType = T.Dict[ | |
| str, | |
| T.Union[ | |
| PlaceholderTypes, | |
| T.Dict[str, PlaceholderTypes], | |
| T.Dict[str, T.Dict[str, PlaceholderTypes]], | |
| T.Dict[str, T.List[T.Dict[str, PlaceholderTypes]]], | |
| T.List[T.Dict[str, PlaceholderTypes]], | |
| T.List[str], | |
| ] | |
| ] | |
| _ROOT_BASENAME = 'Reference-manual' | |
| _OBJ_ID_MAP = { | |
| ObjectType.ELEMENTARY: 'elementary', | |
| ObjectType.BUILTIN: 'builtin', | |
| ObjectType.MODULE: 'module', | |
| ObjectType.FUNCTIONS: 'functions', | |
| ObjectType.RETURNED: 'returned', | |
| } | |
| # Indent all but the first line with 4*depth spaces. | |
| # This function is designed to be used with `dedent` | |
| # and fstrings where multiline strings are used during | |
| # the string interpolation. | |
| def smart_indent(raw: str, depth: int = 3) -> str: | |
| lines = raw.split('\n') | |
| first_line = lines[0] | |
| lines = [' ' * (4 * depth) + x for x in lines] | |
| lines[0] = first_line # Do not indent the first line | |
| return '\n'.join(lines) | |
| def code_block(code: str) -> str: | |
| code = dedent(code) | |
| return f'<pre><code class="language-meson">{code}</code></pre>' | |
| class GeneratorMD(GeneratorBase): | |
| def __init__(self, manual: ReferenceManual, sitemap_out: Path, sitemap_in: Path, link_def_out: Path, enable_modules: bool) -> None: | |
| super().__init__(manual) | |
| self.sitemap_out = sitemap_out.resolve() | |
| self.sitemap_in = sitemap_in.resolve() | |
| self.link_def_out = link_def_out.resolve() | |
| self.out_dir = self.sitemap_out.parent | |
| self.enable_modules = enable_modules | |
| self.generated_files: T.Dict[str, str] = {} | |
| # Utility functions | |
| def _gen_filename(self, file_id: str, *, extension: str = 'md') -> str: | |
| parts = file_id.split('.') | |
| assert parts[0] == 'root' | |
| assert all([x for x in parts]) | |
| parts[0] = _ROOT_BASENAME | |
| parts = [re.sub(r'[0-9]+_', '', x) for x in parts] | |
| return f'{"_".join(parts)}.{extension}' | |
| def _gen_object_file_id(self, obj: Object) -> str: | |
| ''' | |
| Deterministically generate a unique file ID for the Object. | |
| This ID determines where the object will be inserted in the sitemap. | |
| ''' | |
| if obj.obj_type == ObjectType.RETURNED and obj.defined_by_module is not None: | |
| base = self._gen_object_file_id(obj.defined_by_module) | |
| return f'{base}.{obj.name}' | |
| return f'root.{_OBJ_ID_MAP[obj.obj_type]}.{obj.name}' | |
| def _link_to_object(self, obj: T.Union[Function, Object], in_code_block: bool = False) -> str: | |
| ''' | |
| Generate a placeholder tag for the function/method/object documentation. | |
| This tag is then replaced in the custom hotdoc plugin. | |
| ''' | |
| prefix = '#' if in_code_block else '' | |
| if isinstance(obj, Object): | |
| return f'[[{prefix}@{obj.name}]]' | |
| elif isinstance(obj, Method): | |
| return f'[[{prefix}{obj.obj.name}.{obj.name}]]' | |
| elif isinstance(obj, Function): | |
| return f'[[{prefix}{obj.name}]]' | |
| else: | |
| raise RuntimeError(f'Invalid argument {obj}') | |
| def _write_file(self, data: str, file_id: str) -> None:# | |
| ''' Write the data to disk and store the id for the generated data ''' | |
| self.generated_files[file_id] = self._gen_filename(file_id) | |
| out_file = self.out_dir / self.generated_files[file_id] | |
| out_file.write_text(data, encoding='ascii') | |
| mlog.log('Generated', mlog.bold(out_file.name)) | |
| def _write_template(self, data: T.Dict[str, T.Any], file_id: str, template_name: T.Optional[str] = None) -> None: | |
| ''' Render the template mustache files and write the result ''' | |
| template_dir = Path(__file__).resolve().parent / 'templates' | |
| template_name = template_name or file_id | |
| template_name = f'{template_name}.mustache' | |
| template_file = template_dir / template_name | |
| # Import here, so that other generators don't also depend on it | |
| import chevron | |
| result = chevron.render( | |
| template=template_file.read_text(encoding='utf-8'), | |
| data=data, | |
| partials_path=template_dir.as_posix(), | |
| warn=True, | |
| ) | |
| self._write_file(result, file_id) | |
| # Actual generator functions | |
| def _gen_func_or_method(self, func: Function) -> FunctionDictType: | |
| def render_type(typ: Type, in_code_block: bool = False) -> str: | |
| def data_type_to_str(dt: DataTypeInfo) -> str: | |
| base = self._link_to_object(dt.data_type, in_code_block) | |
| if dt.holds: | |
| return f'{base}[{render_type(dt.holds, in_code_block)}]' | |
| return base | |
| assert typ.resolved | |
| return ' | '.join([data_type_to_str(x) for x in typ.resolved]) | |
| def len_stripped(s: str) -> int: | |
| s = s.replace(']]', '') | |
| # I know, this regex is ugly but it works. | |
| return len(re.sub(r'\[\[(#|@)*([^\[])', r'\2', s)) | |
| def arg_anchor(arg: ArgBase) -> str: | |
| return f'{func.name}_{arg.name.replace("<", "_").replace(">", "_")}' | |
| def render_signature() -> str: | |
| # Skip a lot of computations if the function does not take any arguments | |
| if not any([func.posargs, func.optargs, func.kwargs, func.varargs]): | |
| return f'{render_type(func.returns, True)} {func.name}()' | |
| signature = dedent(f'''\ | |
| # {self.brief(func)} | |
| {render_type(func.returns, True)} {func.name}( | |
| ''') | |
| # Calculate maximum lengths of the type and name | |
| all_args: T.List[ArgBase] = [] | |
| all_args += func.posargs | |
| all_args += func.optargs | |
| all_args += [func.varargs] if func.varargs else [] | |
| max_type_len = 0 | |
| max_name_len = 0 | |
| if all_args: | |
| max_type_len = max([len_stripped(render_type(x.type)) for x in all_args]) | |
| max_name_len = max([len(x.name) for x in all_args]) | |
| # Generate some common strings | |
| def prepare(arg: ArgBase, link: bool = True) -> T.Tuple[str, str, str, str]: | |
| type_str = render_type(arg.type, True) | |
| type_len = len_stripped(type_str) | |
| type_space = ' ' * (max_type_len - type_len) | |
| name_space = ' ' * (max_name_len - len(arg.name)) | |
| name_str = f'<b>{arg.name.replace("<", "<").replace(">", ">")}</b>' | |
| if link: | |
| name_str = f'<a href="#{arg_anchor(arg)}">{name_str}</a>' | |
| return type_str, type_space, name_str, name_space | |
| for i in func.posargs: | |
| type_str, type_space, name_str, name_space = prepare(i) | |
| signature += f' {type_str}{type_space} {name_str},{name_space} # {self.brief(i)}\n' | |
| for i in func.optargs: | |
| type_str, type_space, name_str, name_space = prepare(i) | |
| signature += f' {type_str}{type_space} [{name_str}],{name_space} # {self.brief(i)}\n' | |
| if func.varargs: | |
| type_str, type_space, name_str, name_space = prepare(func.varargs, link=False) | |
| signature += f' {type_str}{type_space} {name_str}...,{name_space} # {self.brief(func.varargs)}\n' | |
| # Abort if there are no kwargs | |
| if not func.kwargs: | |
| return signature + ')' | |
| # Only add this separator if there are any posargs | |
| if all_args: | |
| signature += '\n # Keyword arguments:\n' | |
| # Recalculate lengths for kwargs | |
| all_args = list(func.kwargs.values()) | |
| max_type_len = max([len_stripped(render_type(x.type)) for x in all_args]) | |
| max_name_len = max([len(x.name) for x in all_args]) | |
| for kwarg in self.sorted_and_filtered(list(func.kwargs.values())): | |
| type_str, type_space, name_str, name_space = prepare(kwarg) | |
| required = ' <i>[required]</i> ' if kwarg.required else ' ' | |
| required = required if any([x.required for x in func.kwargs.values()]) else '' | |
| signature += f' {name_str}{name_space} : {type_str}{type_space} {required} # {self.brief(kwarg)}\n' | |
| return signature + ')' | |
| def gen_arg_data(arg: T.Union[PosArg, Kwarg, VarArgs], *, optional: bool = False) -> T.Dict[str, PlaceholderTypes]: | |
| data: T.Dict[str, PlaceholderTypes] = { | |
| 'row-id': arg_anchor(arg), | |
| 'name': arg.name, | |
| 'type': render_type(arg.type), | |
| 'description': arg.description, | |
| 'since': arg.since or None, | |
| 'deprecated': arg.deprecated or None, | |
| 'optional': optional, | |
| 'default': None, | |
| } | |
| if isinstance(arg, VarArgs): | |
| data.update({ | |
| 'min': str(arg.min_varargs) if arg.min_varargs > 0 else '0', | |
| 'max': str(arg.max_varargs) if arg.max_varargs > 0 else 'infinity', | |
| }) | |
| if isinstance(arg, (Kwarg, PosArg)): | |
| data.update({'default': arg.default or None}) | |
| if isinstance(arg, Kwarg): | |
| data.update({'required': arg.required}) | |
| return data | |
| mname = f'\\{func.name}' if func.name == '[index]' else func.name | |
| data: FunctionDictType = { | |
| 'name': f'{func.obj.name}.{mname}' if isinstance(func, Method) else func.name, | |
| 'base_level': '##' if isinstance(func, Method) else '#', | |
| 'type_name_upper': 'Method' if isinstance(func, Method) else 'Function', | |
| 'type_name': 'method' if isinstance(func, Method) else 'function', | |
| 'description': func.description, | |
| 'notes': func.notes, | |
| 'warnings': func.warnings, | |
| 'example': func.example or None, | |
| 'signature_level': 'h4' if isinstance(func, Method) else 'h3', | |
| 'signature': render_signature(), | |
| 'has_args': bool(func.posargs or func.optargs or func.kwargs or func.varargs), | |
| # Merge posargs and optargs by generating the *[optional]* tag for optargs | |
| 'posargs': { | |
| 'args': [gen_arg_data(x) for x in func.posargs] + [gen_arg_data(x, optional=True) for x in func.optargs] | |
| } if func.posargs or func.optargs else None, | |
| 'kwargs': {'args': [gen_arg_data(x) for x in self.sorted_and_filtered(list(func.kwargs.values()))]} if func.kwargs else None, | |
| 'varargs': gen_arg_data(func.varargs) if func.varargs else None, | |
| 'arg_flattening': func.arg_flattening, | |
| # For the feature taggs template | |
| 'since': func.since or None, | |
| 'deprecated': func.deprecated or None, | |
| 'optional': False, | |
| 'default': None | |
| } | |
| return data | |
| def _write_object(self, obj: Object) -> None: | |
| data = { | |
| 'name': obj.name, | |
| 'title': obj.long_name if obj.obj_type == ObjectType.RETURNED else obj.name, | |
| 'description': obj.description, | |
| 'notes': obj.notes, | |
| 'warnings': obj.warnings, | |
| 'long_name': obj.long_name, | |
| 'obj_type_name': _OBJ_ID_MAP[obj.obj_type].capitalize(), | |
| 'example': obj.example or None, | |
| 'has_methods': bool(obj.methods), | |
| 'has_inherited_methods': bool(obj.inherited_methods), | |
| 'has_subclasses': bool(obj.extended_by), | |
| 'is_returned': bool(obj.returned_by), | |
| 'extends': obj.extends_obj.name if obj.extends_obj else None, | |
| 'returned_by': [self._link_to_object(x) for x in self.sorted_and_filtered(obj.returned_by)], | |
| 'extended_by': [self._link_to_object(x) for x in self.sorted_and_filtered(obj.extended_by)], | |
| 'methods': [self._gen_func_or_method(m) for m in self.sorted_and_filtered(obj.methods)], | |
| 'inherited_methods': [self._gen_func_or_method(m) for m in self.sorted_and_filtered(obj.inherited_methods)], | |
| } | |
| self._write_template(data, self._gen_object_file_id(obj), 'object') | |
| def _write_functions(self) -> None: | |
| data = {'functions': [self._gen_func_or_method(x) for x in self.functions]} | |
| self._write_template(data, f'root.{_OBJ_ID_MAP[ObjectType.FUNCTIONS]}') | |
| def _root_refman_docs(self) -> None: | |
| def gen_obj_links(objs: T.List[Object]) -> T.List[T.Dict[str, str]]: | |
| ret: T.List[T.Dict[str, str]] = [] | |
| for o in objs: | |
| ret += [{'indent': '', 'link': self._link_to_object(o), 'brief': self.brief(o)}] | |
| for m in self.sorted_and_filtered(o.methods): | |
| ret += [{'indent': ' ', 'link': self._link_to_object(m), 'brief': self.brief(m)}] | |
| if o.obj_type == ObjectType.MODULE and self.extract_returned_by_module(o): | |
| tmp = gen_obj_links(self.extract_returned_by_module(o)) | |
| tmp = [{**x, 'indent': ' ' + x['indent']} for x in tmp] | |
| ret += [{'indent': ' ', 'link': '**New objects:**', 'brief': ''}] | |
| ret += [*tmp] | |
| return ret | |
| data = { | |
| 'root': self._gen_filename('root'), | |
| 'elementary': gen_obj_links(self.elementary), | |
| 'returned': gen_obj_links(self.returned), | |
| 'builtins': gen_obj_links(self.builtins), | |
| 'modules': gen_obj_links(self.modules), | |
| 'functions': [{'indent': '', 'link': self._link_to_object(x), 'brief': self.brief(x)} for x in self.functions], | |
| 'enable_modules': self.enable_modules, | |
| } | |
| dummy = {'root': self._gen_filename('root')} | |
| self._write_template(data, 'root') | |
| self._write_template({**dummy, 'name': 'Elementary types'}, f'root.{_OBJ_ID_MAP[ObjectType.ELEMENTARY]}', 'dummy') | |
| self._write_template({**dummy, 'name': 'Builtin objects'}, f'root.{_OBJ_ID_MAP[ObjectType.BUILTIN]}', 'dummy') | |
| self._write_functions() | |
| self._write_template({**dummy, 'name': 'Returned objects'}, f'root.{_OBJ_ID_MAP[ObjectType.RETURNED]}', 'dummy') | |
| if self.enable_modules: | |
| self._write_template({**dummy, 'name': 'Modules'}, f'root.{_OBJ_ID_MAP[ObjectType.MODULE]}', 'dummy') | |
| def generate(self) -> None: | |
| mlog.log('Generating markdown files...') | |
| with mlog.nested(): | |
| for fun in self.functions: | |
| self._write_template(self._gen_func_or_method(fun), f'root.{_OBJ_ID_MAP[ObjectType.FUNCTIONS]}.{fun.name}', 'func') | |
| for obj in self.objects: | |
| if not self.enable_modules and (obj.obj_type == ObjectType.MODULE or obj.defined_by_module is not None): | |
| continue | |
| self._write_object(obj) | |
| self._root_refman_docs() | |
| self._configure_sitemap() | |
| self._generate_link_def() | |
| def _configure_sitemap(self) -> None: | |
| ''' | |
| Replaces the `@REFMAN_PLACEHOLDER@` placeholder with the reference | |
| manual sitemap. The structure of the sitemap is derived from the | |
| file IDs. | |
| ''' | |
| raw = self.sitemap_in.read_text(encoding='utf-8') | |
| out = '' | |
| for l in raw.split('\n'): | |
| if '@REFMAN_PLACEHOLDER@' not in l: | |
| out += f'{l}\n' | |
| continue | |
| mlog.log('Generating', mlog.bold(self.sitemap_out.as_posix())) | |
| base_indent = l.replace('@REFMAN_PLACEHOLDER@', '') | |
| for k in sorted(self.generated_files.keys()): | |
| indent = base_indent + '\t' * k.count('.') | |
| out += f'{indent}{self.generated_files[k]}\n' | |
| self.sitemap_out.write_text(out, encoding='utf-8') | |
| def _generate_link_def(self) -> None: | |
| ''' | |
| Generate the link definition file for the refman_links hotdoc | |
| plugin. The plugin is then responsible for replacing the [[tag]] | |
| tags with custom HTML elements. | |
| ''' | |
| data: T.Dict[str, str] = {} | |
| # Objects and methods | |
| for obj in self.objects: | |
| obj_file = self._gen_filename(self._gen_object_file_id(obj), extension='html') | |
| data[f'@{obj.name}'] = obj_file | |
| for m in obj.methods: | |
| data[f'{obj.name}.{m.name}'] = f'{obj_file}#{obj.name}{m.name}' | |
| # Functions | |
| funcs_file = self._gen_filename(f'root.{_OBJ_ID_MAP[ObjectType.FUNCTIONS]}', extension='html') | |
| for fn in self.functions: | |
| data[fn.name] = f'{funcs_file}#{fn.name}' | |
| self.link_def_out.write_text(json.dumps(data, indent=2), encoding='utf-8') |