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 . import get_include_args
26from ..dependencies import Dependency, InternalDependency, ExternalProgram
27from ..interpreterbase import FeatureNew, InvalidArguments, noPosargs, noKwargs
28from ..interpreter import CustomTargetHolder
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(['%s=%s' % (option, value)])
104            else:
105                self.cmd.extend([option, value])
106
107    def check_extra_arg_type(self, arg, value):
108        value = getattr(value, 'held_object', value)
109        if isinstance(value, list):
110            for v in value:
111                self.check_extra_arg_type(arg, v)
112            return
113
114        valid_types = (str, bool, mesonlib.File, build.IncludeDirs, build.CustomTarget, build.BuildTarget)
115        if not isinstance(value, valid_types):
116            raise InvalidArguments('Argument "%s=%s" should be of type: %s.' % (
117                arg, value, [t.__name__ for t in valid_types]))
118
119    def process_extra_args(self):
120        for arg, value in self.kwargs.items():
121            option = "--" + arg.replace("_", "-")
122            self.check_extra_arg_type(arg, value)
123            self.set_arg_value(option, value)
124
125    def get_value(self, types, argname, default=None, value_processor=None,
126                  mandatory=False, force_list=False):
127        if not isinstance(types, list):
128            types = [types]
129        try:
130            uvalue = value = self.kwargs.pop(argname)
131            if value_processor:
132                value = value_processor(value)
133
134            for t in types:
135                if isinstance(value, t):
136                    if force_list and not isinstance(value, list):
137                        return [value], uvalue
138                    return value, uvalue
139            raise MesonException("%s field value %s is not valid,"
140                                 " valid types are %s" % (argname, value,
141                                                          types))
142        except KeyError:
143            if mandatory:
144                raise MesonException("%s mandatory field not found" % argname)
145
146            if default is not None:
147                return default, default
148
149        return None, None
150
151    def setup_extension_paths(self, paths):
152        if not isinstance(paths, list):
153            paths = [paths]
154
155        for path in paths:
156            self.add_extension_paths([path])
157
158        return []
159
160    def add_extension_paths(self, paths):
161        for path in paths:
162            if path in self._extra_extension_paths:
163                continue
164
165            self._extra_extension_paths.add(path)
166            self.cmd.extend(["--extra-extension-path", path])
167
168    def process_extra_extension_paths(self):
169        self.get_value([list, str], 'extra_extensions_paths',
170                       default="", value_processor=self.setup_extension_paths)
171
172    def replace_dirs_in_string(self, string):
173        return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir)
174
175    def process_gi_c_source_roots(self):
176        if self.hotdoc.run_hotdoc(['--has-extension=gi-extension']) != 0:
177            return
178
179        value, _ = self.get_value([list, str], 'gi_c_source_roots', default=[], force_list=True)
180        value.extend([
181            os.path.join(self.state.environment.get_source_dir(),
182                         self.interpreter.subproject_dir, self.state.subproject),
183            os.path.join(self.state.environment.get_build_dir(), self.interpreter.subproject_dir, self.state.subproject)
184        ])
185
186        self.cmd += ['--gi-c-source-roots'] + value
187
188    def process_dependencies(self, deps):
189        cflags = set()
190        for dep in mesonlib.listify(ensure_list(deps)):
191            dep = getattr(dep, "held_object", dep)
192            if isinstance(dep, InternalDependency):
193                inc_args = get_include_args(dep.include_directories)
194                cflags.update([self.replace_dirs_in_string(x)
195                               for x in inc_args])
196                cflags.update(self.process_dependencies(dep.libraries))
197                cflags.update(self.process_dependencies(dep.sources))
198                cflags.update(self.process_dependencies(dep.ext_deps))
199            elif isinstance(dep, Dependency):
200                cflags.update(dep.get_compile_args())
201            elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)):
202                self._dependencies.append(dep)
203                for incd in dep.get_include_dirs():
204                    cflags.update(incd.get_incdirs())
205            elif isinstance(dep, HotdocTarget):
206                # Recurse in hotdoc target dependencies
207                self.process_dependencies(dep.get_target_dependencies())
208                self._subprojects.extend(dep.subprojects)
209                self.process_dependencies(dep.subprojects)
210                self.add_include_path(os.path.join(self.builddir, dep.hotdoc_conf.subdir))
211                self.cmd += ['--extra-assets=' + p for p in dep.extra_assets]
212                self.add_extension_paths(dep.extra_extension_paths)
213            elif isinstance(dep, build.CustomTarget) or isinstance(dep, build.BuildTarget):
214                self._dependencies.append(dep)
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            arg = getattr(arg, 'held_object', arg)
236            if isinstance(arg, mesonlib.File):
237                arg = arg.absolute_path(self.state.environment.get_source_dir(),
238                                        self.state.environment.get_build_dir())
239            elif isinstance(arg, build.IncludeDirs):
240                for inc_dir in arg.get_incdirs():
241                    cmd.append(os.path.join(self.sourcedir, arg.get_curdir(), inc_dir))
242                    cmd.append(os.path.join(self.builddir, arg.get_curdir(), inc_dir))
243
244                continue
245            elif isinstance(arg, build.CustomTarget) or isinstance(arg, build.BuildTarget):
246                self._dependencies.append(arg)
247                arg = self.interpreter.backend.get_target_filename_abs(arg)
248
249            cmd.append(arg)
250
251        return cmd
252
253    def generate_hotdoc_config(self):
254        cwd = os.path.abspath(os.curdir)
255        ncwd = os.path.join(self.sourcedir, self.subdir)
256        mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name))
257        os.chdir(ncwd)
258        self.hotdoc.run_hotdoc(self.flatten_config_command())
259        os.chdir(cwd)
260
261    def ensure_file(self, value):
262        if isinstance(value, list):
263            res = []
264            for val in value:
265                res.append(self.ensure_file(val))
266            return res
267
268        if not isinstance(value, mesonlib.File):
269            return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value)
270
271        return value
272
273    def ensure_dir(self, value):
274        if os.path.isabs(value):
275            _dir = value
276        else:
277            _dir = os.path.join(self.sourcedir, self.subdir, value)
278
279        if not os.path.isdir(_dir):
280            raise InvalidArguments('"%s" is not a directory.' % _dir)
281
282        return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir))
283
284    def check_forbiden_args(self):
285        for arg in ['conf_file']:
286            if arg in self.kwargs:
287                raise InvalidArguments('Argument "%s" is forbidden.' % arg)
288
289    def add_include_path(self, path):
290        self.include_paths[path] = path
291
292    def make_targets(self):
293        self.check_forbiden_args()
294        file_types = (str, mesonlib.File)
295        self.process_known_arg("--index", file_types, mandatory=True, value_processor=self.ensure_file)
296        self.process_known_arg("--project-version", str, mandatory=True)
297        self.process_known_arg("--sitemap", file_types, mandatory=True, value_processor=self.ensure_file)
298        self.process_known_arg("--html-extra-theme", str, value_processor=self.ensure_dir)
299        self.process_known_arg(None, list, "include_paths", force_list=True,
300                               value_processor=lambda x: [self.add_include_path(self.ensure_dir(v)) for v in ensure_list(x)])
301        self.process_known_arg('--c-include-directories',
302                               [Dependency, build.StaticLibrary, build.SharedLibrary, list], argname="dependencies",
303                               force_list=True, value_processor=self.process_dependencies)
304        self.process_gi_c_source_roots()
305        self.process_extra_assets()
306        self.process_extra_extension_paths()
307        self.process_subprojects()
308
309        install, install = self.get_value(bool, "install", mandatory=False)
310        self.process_extra_args()
311
312        fullname = self.name + '-doc'
313        hotdoc_config_name = fullname + '.json'
314        hotdoc_config_path = os.path.join(
315            self.builddir, self.subdir, hotdoc_config_name)
316        with open(hotdoc_config_path, 'w') as f:
317            f.write('{}')
318
319        self.cmd += ['--conf-file', hotdoc_config_path]
320        self.add_include_path(os.path.join(self.builddir, self.subdir))
321        self.add_include_path(os.path.join(self.sourcedir, self.subdir))
322
323        depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps')
324        self.cmd += ['--deps-file-dest', depfile]
325
326        for path in self.include_paths.keys():
327            self.cmd.extend(['--include-path', path])
328
329        if self.state.environment.coredata.get_builtin_option('werror', self.state.subproject):
330            self.cmd.append('--fatal-warning')
331        self.generate_hotdoc_config()
332
333        target_cmd = self.build_command + ["--internal", "hotdoc"] + \
334            self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \
335            ['--builddir', os.path.join(self.builddir, self.subdir)]
336
337        target = HotdocTarget(fullname,
338                              subdir=self.subdir,
339                              subproject=self.state.subproject,
340                              hotdoc_conf=mesonlib.File.from_built_file(
341                                  self.subdir, hotdoc_config_name),
342                              extra_extension_paths=self._extra_extension_paths,
343                              extra_assets=self._extra_assets,
344                              subprojects=self._subprojects,
345                              command=target_cmd,
346                              depends=self._dependencies,
347                              output=fullname,
348                              depfile=os.path.basename(depfile),
349                              build_by_default=self.build_by_default)
350
351        install_script = None
352        if install is True:
353            install_script = HotdocRunScript(self.build_command, [
354                "--internal", "hotdoc",
355                "--install", os.path.join(fullname, 'html'),
356                '--name', self.name,
357                '--builddir', os.path.join(self.builddir, self.subdir)] +
358                self.hotdoc.get_command() +
359                ['run', '--conf-file', hotdoc_config_name])
360
361        return (target, install_script)
362
363
364class HotdocTargetHolder(CustomTargetHolder):
365    def __init__(self, target, interp):
366        super().__init__(target, interp)
367        self.methods.update({'config_path': self.config_path_method})
368
369    @noPosargs
370    @noKwargs
371    def config_path_method(self, *args, **kwargs):
372        conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir,
373                                                          self.interpreter.environment.build_dir)
374        return self.interpreter.holderify(conf)
375
376
377class HotdocTarget(build.CustomTarget):
378    def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets,
379                 subprojects, **kwargs):
380        super().__init__(name, subdir, subproject, kwargs, absolute_paths=True)
381        self.hotdoc_conf = hotdoc_conf
382        self.extra_extension_paths = extra_extension_paths
383        self.extra_assets = extra_assets
384        self.subprojects = subprojects
385
386    def __getstate__(self):
387        # Make sure we do not try to pickle subprojects
388        res = self.__dict__.copy()
389        res['subprojects'] = []
390
391        return res
392
393
394class HotdocRunScript(build.RunScript):
395    def __init__(self, script, args):
396        super().__init__(script, args)
397
398
399class HotDocModule(ExtensionModule):
400    @FeatureNew('Hotdoc Module', '0.48.0')
401    def __init__(self, interpreter):
402        super().__init__(interpreter)
403        self.hotdoc = ExternalProgram('hotdoc')
404        if not self.hotdoc.found():
405            raise MesonException('hotdoc executable not found')
406
407        try:
408            from hotdoc.run_hotdoc import run  # noqa: F401
409            self.hotdoc.run_hotdoc = run
410        except Exception as e:
411            raise MesonException('hotdoc %s required but not found. (%s)' % (
412                MIN_HOTDOC_VERSION, e))
413
414    @noKwargs
415    def has_extensions(self, state, args, kwargs):
416        res = self.hotdoc.run_hotdoc(['--has-extension=%s' % extension for extension in args]) == 0
417        return ModuleReturnValue(res, [res])
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 = [HotdocTargetHolder(target, self.interpreter)]
428        if install_script:
429            targets.append(install_script)
430
431        return ModuleReturnValue(targets[0], targets)
432
433
434def initialize(interpreter):
435    return HotDocModule(interpreter)
436