1# Copyright 2018 The Meson development team
2
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6
7#     http://www.apache.org/licenses/LICENSE-2.0
8
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15'''This module provides helper functions for generating documentation using hotdoc'''
16
17import os
18from collections import OrderedDict
19
20from mesonbuild import mesonlib
21from mesonbuild import mlog, build
22from mesonbuild.coredata import MesonException
23from . import ModuleReturnValue
24from . import ExtensionModule
25from ..dependencies import Dependency, InternalDependency
26from ..interpreterbase import FeatureNew, InvalidArguments, noPosargs, noKwargs
27from ..interpreter import CustomTargetHolder
28from ..programs import ExternalProgram
29
30
31def ensure_list(value):
32    if not isinstance(value, list):
33        return [value]
34    return value
35
36
37MIN_HOTDOC_VERSION = '0.8.100'
38
39
40class HotdocTargetBuilder:
41    def __init__(self, name, state, hotdoc, interpreter, kwargs):
42        self.hotdoc = hotdoc
43        self.build_by_default = kwargs.pop('build_by_default', False)
44        self.kwargs = kwargs
45        self.name = name
46        self.state = state
47        self.interpreter = interpreter
48        self.include_paths = OrderedDict()
49
50        self.builddir = state.environment.get_build_dir()
51        self.sourcedir = state.environment.get_source_dir()
52        self.subdir = state.subdir
53        self.build_command = state.environment.get_build_command()
54
55        self.cmd = ['conf', '--project-name', name, "--disable-incremental-build",
56                    '--output', os.path.join(self.builddir, self.subdir, self.name + '-doc')]
57
58        self._extra_extension_paths = set()
59        self.extra_assets = set()
60        self._dependencies = []
61        self._subprojects = []
62
63    def process_known_arg(self, option, types, argname=None,
64                          value_processor=None, mandatory=False,
65                          force_list=False):
66        if not argname:
67            argname = option.strip("-").replace("-", "_")
68
69        value, _ = self.get_value(
70            types, argname, None, value_processor, mandatory, force_list)
71
72        self.set_arg_value(option, value)
73
74    def set_arg_value(self, option, value):
75        if value is None:
76            return
77
78        if isinstance(value, bool):
79            if value:
80                self.cmd.append(option)
81        elif isinstance(value, list):
82            # Do not do anything on empty lists
83            if value:
84                # https://bugs.python.org/issue9334 (from 2010 :( )
85                # The syntax with nargs=+ is inherently ambiguous
86                # A workaround for this case is to simply prefix with a space
87                # every value starting with a dash
88                escaped_value = []
89                for e in value:
90                    if isinstance(e, str) and e.startswith('-'):
91                        escaped_value += [' %s' % e]
92                    else:
93                        escaped_value += [e]
94                if option:
95                    self.cmd.extend([option] + escaped_value)
96                else:
97                    self.cmd.extend(escaped_value)
98        else:
99            # argparse gets confused if value(s) start with a dash.
100            # When an option expects a single value, the unambiguous way
101            # to specify it is with =
102            if isinstance(value, str):
103                self.cmd.extend([f'{option}={value}'])
104            else:
105                self.cmd.extend([option, value])
106
107    def check_extra_arg_type(self, arg, value):
108        if isinstance(value, list):
109            for v in value:
110                self.check_extra_arg_type(arg, v)
111            return
112
113        valid_types = (str, bool, mesonlib.File, build.IncludeDirs, build.CustomTarget, build.CustomTargetIndex, build.BuildTarget)
114        if not isinstance(value, valid_types):
115            raise InvalidArguments('Argument "{}={}" should be of type: {}.'.format(
116                arg, value, [t.__name__ for t in valid_types]))
117
118    def process_extra_args(self):
119        for arg, value in self.kwargs.items():
120            option = "--" + arg.replace("_", "-")
121            self.check_extra_arg_type(arg, value)
122            self.set_arg_value(option, value)
123
124    def get_value(self, types, argname, default=None, value_processor=None,
125                  mandatory=False, force_list=False):
126        if not isinstance(types, list):
127            types = [types]
128        try:
129            uvalue = value = self.kwargs.pop(argname)
130            if value_processor:
131                value = value_processor(value)
132
133            for t in types:
134                if isinstance(value, t):
135                    if force_list and not isinstance(value, list):
136                        return [value], uvalue
137                    return value, uvalue
138            raise MesonException("%s field value %s is not valid,"
139                                 " valid types are %s" % (argname, value,
140                                                          types))
141        except KeyError:
142            if mandatory:
143                raise MesonException("%s mandatory field not found" % argname)
144
145            if default is not None:
146                return default, default
147
148        return None, None
149
150    def setup_extension_paths(self, paths):
151        if not isinstance(paths, list):
152            paths = [paths]
153
154        for path in paths:
155            self.add_extension_paths([path])
156
157        return []
158
159    def add_extension_paths(self, paths):
160        for path in paths:
161            if path in self._extra_extension_paths:
162                continue
163
164            self._extra_extension_paths.add(path)
165            self.cmd.extend(["--extra-extension-path", path])
166
167    def process_extra_extension_paths(self):
168        self.get_value([list, str], 'extra_extensions_paths',
169                       default="", value_processor=self.setup_extension_paths)
170
171    def replace_dirs_in_string(self, string):
172        return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir)
173
174    def process_gi_c_source_roots(self):
175        if self.hotdoc.run_hotdoc(['--has-extension=gi-extension']) != 0:
176            return
177
178        value, _ = self.get_value([list, str], 'gi_c_source_roots', default=[], force_list=True)
179        value.extend([
180            os.path.join(self.state.environment.get_source_dir(),
181                         self.interpreter.subproject_dir, self.state.subproject),
182            os.path.join(self.state.environment.get_build_dir(), self.interpreter.subproject_dir, self.state.subproject)
183        ])
184
185        self.cmd += ['--gi-c-source-roots'] + value
186
187    def process_dependencies(self, deps):
188        cflags = set()
189        for dep in mesonlib.listify(ensure_list(deps)):
190            if isinstance(dep, InternalDependency):
191                inc_args = self.state.get_include_args(dep.include_directories)
192                cflags.update([self.replace_dirs_in_string(x)
193                               for x in inc_args])
194                cflags.update(self.process_dependencies(dep.libraries))
195                cflags.update(self.process_dependencies(dep.sources))
196                cflags.update(self.process_dependencies(dep.ext_deps))
197            elif isinstance(dep, Dependency):
198                cflags.update(dep.get_compile_args())
199            elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)):
200                self._dependencies.append(dep)
201                for incd in dep.get_include_dirs():
202                    cflags.update(incd.get_incdirs())
203            elif isinstance(dep, HotdocTarget):
204                # Recurse in hotdoc target dependencies
205                self.process_dependencies(dep.get_target_dependencies())
206                self._subprojects.extend(dep.subprojects)
207                self.process_dependencies(dep.subprojects)
208                self.add_include_path(os.path.join(self.builddir, dep.hotdoc_conf.subdir))
209                self.cmd += ['--extra-assets=' + p for p in dep.extra_assets]
210                self.add_extension_paths(dep.extra_extension_paths)
211            elif isinstance(dep, build.CustomTarget) or isinstance(dep, build.BuildTarget):
212                self._dependencies.append(dep)
213            elif isinstance(dep, build.CustomTargetIndex):
214                self._dependencies.append(dep.target)
215
216        return [f.strip('-I') for f in cflags]
217
218    def process_extra_assets(self):
219        self._extra_assets, _ = self.get_value("--extra-assets", (str, list), default=[],
220                                               force_list=True)
221        for assets_path in self._extra_assets:
222            self.cmd.extend(["--extra-assets", assets_path])
223
224    def process_subprojects(self):
225        _, value = self.get_value([
226            list, HotdocTarget], argname="subprojects",
227            force_list=True, value_processor=self.process_dependencies)
228
229        if value is not None:
230            self._subprojects.extend(value)
231
232    def flatten_config_command(self):
233        cmd = []
234        for arg in mesonlib.listify(self.cmd, flatten=True):
235            if isinstance(arg, mesonlib.File):
236                arg = arg.absolute_path(self.state.environment.get_source_dir(),
237                                        self.state.environment.get_build_dir())
238            elif isinstance(arg, build.IncludeDirs):
239                for inc_dir in arg.get_incdirs():
240                    cmd.append(os.path.join(self.sourcedir, arg.get_curdir(), inc_dir))
241                    cmd.append(os.path.join(self.builddir, arg.get_curdir(), inc_dir))
242
243                continue
244            elif isinstance(arg, (build.BuildTarget, build.CustomTarget)):
245                self._dependencies.append(arg)
246                arg = self.interpreter.backend.get_target_filename_abs(arg)
247            elif isinstance(arg, build.CustomTargetIndex):
248                self._dependencies.append(arg.target)
249                arg = self.interpreter.backend.get_target_filename_abs(arg)
250
251            cmd.append(arg)
252
253        return cmd
254
255    def generate_hotdoc_config(self):
256        cwd = os.path.abspath(os.curdir)
257        ncwd = os.path.join(self.sourcedir, self.subdir)
258        mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name))
259        os.chdir(ncwd)
260        self.hotdoc.run_hotdoc(self.flatten_config_command())
261        os.chdir(cwd)
262
263    def ensure_file(self, value):
264        if isinstance(value, list):
265            res = []
266            for val in value:
267                res.append(self.ensure_file(val))
268            return res
269
270        if isinstance(value, str):
271            return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value)
272
273        return value
274
275    def ensure_dir(self, value):
276        if os.path.isabs(value):
277            _dir = value
278        else:
279            _dir = os.path.join(self.sourcedir, self.subdir, value)
280
281        if not os.path.isdir(_dir):
282            raise InvalidArguments('"%s" is not a directory.' % _dir)
283
284        return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir))
285
286    def check_forbidden_args(self):
287        for arg in ['conf_file']:
288            if arg in self.kwargs:
289                raise InvalidArguments('Argument "%s" is forbidden.' % arg)
290
291    def add_include_path(self, path):
292        self.include_paths[path] = path
293
294    def make_targets(self):
295        self.check_forbidden_args()
296        file_types = (str, mesonlib.File, build.CustomTarget, build.CustomTargetIndex)
297        self.process_known_arg("--index", file_types, mandatory=True, value_processor=self.ensure_file)
298        self.process_known_arg("--project-version", str, mandatory=True)
299        self.process_known_arg("--sitemap", file_types, mandatory=True, value_processor=self.ensure_file)
300        self.process_known_arg("--html-extra-theme", str, value_processor=self.ensure_dir)
301        self.process_known_arg(None, list, "include_paths", force_list=True,
302                               value_processor=lambda x: [self.add_include_path(self.ensure_dir(v)) for v in ensure_list(x)])
303        self.process_known_arg('--c-include-directories',
304                               [Dependency, build.StaticLibrary, build.SharedLibrary, list], argname="dependencies",
305                               force_list=True, value_processor=self.process_dependencies)
306        self.process_gi_c_source_roots()
307        self.process_extra_assets()
308        self.process_extra_extension_paths()
309        self.process_subprojects()
310
311        install, install = self.get_value(bool, "install", mandatory=False)
312        self.process_extra_args()
313
314        fullname = self.name + '-doc'
315        hotdoc_config_name = fullname + '.json'
316        hotdoc_config_path = os.path.join(
317            self.builddir, self.subdir, hotdoc_config_name)
318        with open(hotdoc_config_path, 'w', encoding='utf-8') as f:
319            f.write('{}')
320
321        self.cmd += ['--conf-file', hotdoc_config_path]
322        self.add_include_path(os.path.join(self.builddir, self.subdir))
323        self.add_include_path(os.path.join(self.sourcedir, self.subdir))
324
325        depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps')
326        self.cmd += ['--deps-file-dest', depfile]
327
328        for path in self.include_paths.keys():
329            self.cmd.extend(['--include-path', path])
330
331        if self.state.environment.coredata.get_option(mesonlib.OptionKey('werror', subproject=self.state.subproject)):
332            self.cmd.append('--fatal-warning')
333        self.generate_hotdoc_config()
334
335        target_cmd = self.build_command + ["--internal", "hotdoc"] + \
336            self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \
337            ['--builddir', os.path.join(self.builddir, self.subdir)]
338
339        target = HotdocTarget(fullname,
340                              subdir=self.subdir,
341                              subproject=self.state.subproject,
342                              hotdoc_conf=mesonlib.File.from_built_file(
343                                  self.subdir, hotdoc_config_name),
344                              extra_extension_paths=self._extra_extension_paths,
345                              extra_assets=self._extra_assets,
346                              subprojects=self._subprojects,
347                              command=target_cmd,
348                              depends=self._dependencies,
349                              output=fullname,
350                              depfile=os.path.basename(depfile),
351                              build_by_default=self.build_by_default)
352
353        install_script = None
354        if install is True:
355            install_script = self.state.backend.get_executable_serialisation(self.build_command + [
356                "--internal", "hotdoc",
357                "--install", os.path.join(fullname, 'html'),
358                '--name', self.name,
359                '--builddir', os.path.join(self.builddir, self.subdir)] +
360                self.hotdoc.get_command() +
361                ['run', '--conf-file', hotdoc_config_name])
362            install_script.tag = 'doc'
363
364        return (target, install_script)
365
366
367class HotdocTargetHolder(CustomTargetHolder):
368    def __init__(self, target, interp):
369        super().__init__(target, interp)
370        self.methods.update({'config_path': self.config_path_method})
371
372    @noPosargs
373    @noKwargs
374    def config_path_method(self, *args, **kwargs):
375        conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir,
376                                                          self.interpreter.environment.build_dir)
377        return conf
378
379
380class HotdocTarget(build.CustomTarget):
381    def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets,
382                 subprojects, **kwargs):
383        super().__init__(name, subdir, subproject, kwargs, absolute_paths=True)
384        self.hotdoc_conf = hotdoc_conf
385        self.extra_extension_paths = extra_extension_paths
386        self.extra_assets = extra_assets
387        self.subprojects = subprojects
388
389    def __getstate__(self):
390        # Make sure we do not try to pickle subprojects
391        res = self.__dict__.copy()
392        res['subprojects'] = []
393
394        return res
395
396
397class HotDocModule(ExtensionModule):
398    @FeatureNew('Hotdoc Module', '0.48.0')
399    def __init__(self, interpreter):
400        super().__init__(interpreter)
401        self.hotdoc = ExternalProgram('hotdoc')
402        if not self.hotdoc.found():
403            raise MesonException('hotdoc executable not found')
404
405        try:
406            from hotdoc.run_hotdoc import run  # noqa: F401
407            self.hotdoc.run_hotdoc = run
408        except Exception as e:
409            raise MesonException(f'hotdoc {MIN_HOTDOC_VERSION} required but not found. ({e})')
410        self.methods.update({
411            'has_extensions': self.has_extensions,
412            'generate_doc': self.generate_doc,
413        })
414
415    @noKwargs
416    def has_extensions(self, state, args, kwargs):
417        return self.hotdoc.run_hotdoc(['--has-extension=%s' % extension for extension in args]) == 0
418
419    def generate_doc(self, state, args, kwargs):
420        if len(args) != 1:
421            raise MesonException('One positional argument is'
422                                 ' required for the project name.')
423
424        project_name = args[0]
425        builder = HotdocTargetBuilder(project_name, state, self.hotdoc, self.interpreter, kwargs)
426        target, install_script = builder.make_targets()
427        targets = [target]
428        if install_script:
429            targets.append(install_script)
430
431        return ModuleReturnValue(targets[0], targets)
432
433
434def initialize(interpreter):
435    mod = HotDocModule(interpreter)
436    mod.interpreter.append_holder_map(HotdocTarget, HotdocTargetHolder)
437    return mod
438