diff options
-rw-r--r-- | .gitlab-ci.yml | 45 | ||||
-rw-r--r-- | README.md | 5 | ||||
-rw-r--r-- | doc/.clang-tidy | 11 | ||||
-rw-r--r-- | doc/Doxyfile.in | 72 | ||||
-rw-r--r-- | doc/api/meson.build | 9 | ||||
-rw-r--r-- | doc/conf.py | 95 | ||||
-rw-r--r-- | doc/index.rst | 15 | ||||
-rw-r--r-- | doc/meson.build | 78 | ||||
-rw-r--r-- | doc/overview_code.c | 51 | ||||
-rw-r--r-- | doc/string_views.rst | 56 | ||||
-rw-r--r-- | doc/summary.rst | 5 | ||||
-rw-r--r-- | doc/using_zix.rst | 12 | ||||
-rw-r--r-- | doc/xml/meson.build | 19 | ||||
-rw-r--r-- | meson.build | 9 | ||||
-rw-r--r-- | meson_options.txt | 3 | ||||
-rwxr-xr-x | scripts/dox_to_sphinx.py | 698 | ||||
-rw-r--r-- | scripts/meson.build | 14 |
17 files changed, 1165 insertions, 32 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c7d73e..0f3c41b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,58 +4,59 @@ arm32_dbg: image: lv2plugin/debian-arm32 script: - - meson setup build --cross-file=/usr/share/meson/cross/arm-linux-gnueabihf.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/arm-linux-gnueabihf.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test arm32_rel: image: lv2plugin/debian-arm32 script: - - meson setup build --cross-file=/usr/share/meson/cross/arm-linux-gnueabihf.ini -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/arm-linux-gnueabihf.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test arm64_dbg: image: lv2plugin/debian-arm64 script: - - meson setup build --cross-file=/usr/share/meson/cross/aarch64-linux-gnu.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/aarch64-linux-gnu.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test arm64_rel: image: lv2plugin/debian-arm64 script: - - meson setup build --cross-file=/usr/share/meson/cross/aarch64-linux-gnu.ini -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/aarch64-linux-gnu.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test x64_dbg: image: lv2plugin/debian-x64 script: - - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true -Db_coverage=true + - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true -Db_coverage=true -Ddocs=enabled - ninja -C build test - ninja -C build coverage-html coverage: '/ *lines\.*: \d+\.\d+.*/' artifacts: paths: + - build/doc - build/meson-logs/coveragereport x64_rel: image: lv2plugin/debian-x64 script: - - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test x64_static: image: lv2plugin/debian-x64 script: - - meson setup build -Ddefault_library=static -Dstrict=true -Dwerror=true + - meson setup build -Ddefault_library=static -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test x64_sanitize: image: lv2plugin/debian-x64-clang script: - - meson setup build -Db_lundef=false -Dbuildtype=plain -Dstrict=true -Dwerror=true -Dc_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero -fsanitize=implicit-conversion -fsanitize=local-bounds -fsanitize=nullability" -Dc_link_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero -fsanitize=implicit-conversion -fsanitize=local-bounds -fsanitize=nullability" -Dcpp_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero" -Dcpp_link_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero" + - meson setup build -Db_lundef=false -Dbuildtype=plain -Dstrict=true -Dwerror=true -Dc_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero -fsanitize=implicit-conversion -fsanitize=local-bounds -fsanitize=nullability" -Dc_link_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero -fsanitize=implicit-conversion -fsanitize=local-bounds -fsanitize=nullability" -Dcpp_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero" -Dcpp_link_args="-fno-sanitize-recover=all -fsanitize=address -fsanitize=undefined -fsanitize=float-divide-by-zero" -Ddocs=disabled - ninja -C build test - meson configure build -Dbuildtype=debugoptimized -Dc_args="" -Dc_link_args="" -Dcpp_args="" -Dcpp_link_args="" - meson configure build -Db_sanitize=thread @@ -78,20 +79,20 @@ fedora: freebsd_dbg: tags: [freebsd,meson] script: - - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test freebsd_rel: tags: [freebsd,meson] script: - - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test mingw32_dbg: image: lv2plugin/debian-mingw32 script: - - meson setup build --cross-file=/usr/share/meson/cross/i686-w64-mingw32.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/i686-w64-mingw32.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test variables: WINEPATH: "Z:\\usr\\lib\\gcc\\i686-w64-mingw32\\10-win32" @@ -99,7 +100,7 @@ mingw32_dbg: mingw32_rel: image: lv2plugin/debian-mingw32 script: - - meson setup build --cross-file=/usr/share/meson/cross/i686-w64-mingw32.ini -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/i686-w64-mingw32.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test variables: WINEPATH: "Z:\\usr\\lib\\gcc\\i686-w64-mingw32\\10-win32" @@ -108,7 +109,7 @@ mingw32_rel: mingw64_dbg: image: lv2plugin/debian-mingw64 script: - - meson setup build --cross-file=/usr/share/meson/cross/x86_64-w64-mingw32.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/x86_64-w64-mingw32.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test variables: WINEPATH: "Z:\\usr\\lib\\gcc\\x86_64-w64-mingw32\\10-win32" @@ -116,7 +117,7 @@ mingw64_dbg: mingw64_rel: image: lv2plugin/debian-mingw64 script: - - meson setup build --cross-file=/usr/share/meson/cross/x86_64-w64-mingw32.ini -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build --cross-file=/usr/share/meson/cross/x86_64-w64-mingw32.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test variables: WINEPATH: "Z:\\usr\\lib\\gcc\\x86_64-w64-mingw32\\10-win32" @@ -131,45 +132,47 @@ mac_dbg: mac_rel: tags: [macos] script: - - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test win_dbg: tags: [windows,meson] script: - - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test win_rel: tags: [windows,meson] script: - - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true + - meson setup build -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddocs=disabled - ninja -C build test wasm_dbg: image: lv2plugin/debian-wasm script: - - meson setup build --cross-file=/usr/share/meson/cross/wasm.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddefault_library=static + - meson setup build --cross-file=/usr/share/meson/cross/wasm.ini -Dbuildtype=debug -Dstrict=true -Dwerror=true -Ddefault_library=static -Ddocs=disabled - ninja -C build test wasm_rel: image: lv2plugin/debian-wasm script: - - meson setup build --cross-file=/usr/share/meson/cross/wasm.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddefault_library=static + - meson setup build --cross-file=/usr/share/meson/cross/wasm.ini -Dbuildtype=release -Dstrict=true -Dwerror=true -Ddefault_library=static -Ddocs=disabled - ninja -C build test pages: script: - - mkdir -p .public + - mkdir -p .public/doc - mv build/meson-logs/coveragereport/ .public/coverage + - mv build/doc/singlehtml .public/doc/ + - mv build/doc/html .public/doc/ - mv .public public needs: - x64_dbg artifacts: paths: - - public + - public only: - main @@ -38,9 +38,8 @@ None, except the C standard library. Documentation ------------- -Public interfaces are well-documented in the [headers](include/zix/). There is -no external documentation at this time. - * [Installation Instructions](INSTALL.md) + * [API reference (single page)](https://drobilla.gitlab.io/zix/doc/singlehtml) + * [API reference (paginated)](https://drobilla.gitlab.io/zix/doc/html) -- David Robillard <d@drobilla.net> diff --git a/doc/.clang-tidy b/doc/.clang-tidy new file mode 100644 index 0000000..3d0f771 --- /dev/null +++ b/doc/.clang-tidy @@ -0,0 +1,11 @@ +# Copyright 2020-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +Checks: > + *, + -altera-*, + -clang-analyzer-deadcode.DeadStores, + -llvmlibc-*, +WarningsAsErrors: '*' +HeaderFilterRegex: '.*' +FormatStyle: file diff --git a/doc/Doxyfile.in b/doc/Doxyfile.in new file mode 100644 index 0000000..52f6dda --- /dev/null +++ b/doc/Doxyfile.in @@ -0,0 +1,72 @@ +# Copyright 2021-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +PROJECT_NAME = Zix +PROJECT_BRIEF = "A lightweight C library of portability wrappers and data structures" + +QUIET = YES +WARN_AS_ERROR = YES +WARN_IF_UNDOCUMENTED = YES +WARN_NO_PARAMDOC = NO + +RECURSIVE=YES + +JAVADOC_AUTOBRIEF = YES + +FULL_PATH_NAMES = NO +CASE_SENSE_NAMES = YES +HIDE_IN_BODY_DOCS = YES +REFERENCES_LINK_SOURCE = NO + +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +XML_PROGRAMLISTING = NO +SHOW_FILES = NO + + +EXPAND_ONLY_PREDEF = YES +MACRO_EXPANSION = YES +SKIP_FUNCTION_MACROS = NO +PREDEFINED = ZIX_ALLOCATED= \ + ZIX_ALWAYS_INLINE_FUNC= \ + ZIX_API= \ + ZIX_BEGIN_DECLS= \ + ZIX_CONST_API= \ + ZIX_CONST_FUNC= \ + ZIX_END_DECLS= \ + ZIX_MALLOC_API= \ + ZIX_MALLOC_FUNC= \ + ZIX_NONNULL= \ + ZIX_NULLABLE= \ + ZIX_PURE_API= \ + ZIX_PURE_FUNC= \ + ZIX_PURE_WIN_API= \ + ZIX_THREAD_FUNC= \ + +STRIP_FROM_PATH = @ZIX_SRCDIR@ +INPUT = @ZIX_SRCDIR@/include/zix/zix.h \ + \ + @ZIX_SRCDIR@/include/zix/attributes.h \ + @ZIX_SRCDIR@/include/zix/status.h \ + @ZIX_SRCDIR@/include/zix/string_view.h \ + \ + @ZIX_SRCDIR@/include/zix/allocator.h \ + @ZIX_SRCDIR@/include/zix/bump_allocator.h \ + \ + @ZIX_SRCDIR@/include/zix/digest.h \ + @ZIX_SRCDIR@/include/zix/function_types.h \ + \ + @ZIX_SRCDIR@/include/zix/bitset.h \ + @ZIX_SRCDIR@/include/zix/btree.h \ + @ZIX_SRCDIR@/include/zix/hash.h \ + @ZIX_SRCDIR@/include/zix/ring.h \ + @ZIX_SRCDIR@/include/zix/tree.h \ + \ + @ZIX_SRCDIR@/include/zix/sem.h \ + @ZIX_SRCDIR@/include/zix/thread.h \ + \ + @ZIX_SRCDIR@/include/zix/filesystem.h \ + @ZIX_SRCDIR@/include/zix/path.h \ + +OUTPUT_DIRECTORY = @DOX_OUTPUT@ diff --git a/doc/api/meson.build b/doc/api/meson.build new file mode 100644 index 0000000..b6a1adb --- /dev/null +++ b/doc/api/meson.build @@ -0,0 +1,9 @@ +# Copyright 2021 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +c_zix_rst = custom_target( + 'zix.rst', + command: [dox_to_sphinx, '-f', '@INPUT0@', '@OUTDIR@'], + input: [c_index_xml] + c_rst_files, + output: 'zix.rst', +) diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..e8a057c --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,95 @@ +# Copyright 2021-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +# Project information + +project = "Zix" +copyright = "2011-2022, David Robillard" +author = "David Robillard" +release = "dev" +desc = "A lightweight C library of portability wrappers and data structures" + +# General configuration + +exclude_patterns = ["xml"] +language = "en" +nitpicky = True +pygments_style = "friendly" + +# Ignore everything opaque or external for nitpicky mode +_opaque = [ + "FILE", + "ZixAllocator", + "ZixAllocatorImpl", + "ZixBTree", + "ZixBTreeImpl", + "ZixBTreeNode", + "ZixBTreeNodeImpl", + "ZixHash", + "ZixHashImpl", + "ZixRing", + "ZixRingImpl", + "ZixSem", + "ZixSemImpl", + "ZixTree", + "ZixTreeImpl", + "ZixTreeIter", + "ZixTreeNode", + "ZixTreeNodeImpl", + "int64_t", + "pthread_t", + "ptrdiff_t", + "size_t", + "uint16_t", + "uint32_t", + "uint64_t", + "uint8_t", +] + +_c_nitpick_ignore = map(lambda x: ("c:identifier", x), _opaque) +_cpp_nitpick_ignore = map(lambda x: ("cpp:identifier", x), _opaque) +nitpick_ignore = list(_c_nitpick_ignore) + list(_cpp_nitpick_ignore) + +# HTML output + +html_copy_source = False +html_short_title = "Zix" +html_theme = "sphinx_lv2_theme" + +if tags.has("singlehtml"): + html_sidebars = { + "**": [ + "globaltoc.html", + ] + } + + html_theme_options = { + "body_max_width": "48em", + "body_min_width": "48em", + "description": desc, + "show_footer_version": True, + "show_logo_version": True, + "logo_name": True, + "logo_width": "8em", + "nosidebar": False, + "page_width": "80em", + "sidebar_width": "18em", + "globaltoc_maxdepth": 3, + "globaltoc_collapse": False, + } + +else: + html_theme_options = { + "body_max_width": "60em", + "body_min_width": "40em", + "description": desc, + "show_footer_version": True, + "show_logo_version": True, + "logo_name": True, + "logo_width": "8em", + "nosidebar": True, + "page_width": "60em", + "sidebar_width": "14em", + "globaltoc_maxdepth": 1, + "globaltoc_collapse": True, + } diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..56062e6 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,15 @@ +.. + Copyright 2020-2022 David Robillard <d@drobilla.net> + SPDX-License-Identifier: ISC + +### +Zix +### + +.. include:: summary.rst + +.. toctree:: + :numbered: + + using_zix + api/zix diff --git a/doc/meson.build b/doc/meson.build new file mode 100644 index 0000000..f406624 --- /dev/null +++ b/doc/meson.build @@ -0,0 +1,78 @@ +# Copyright 2021-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +docdir = get_option('datadir') / 'doc' + +doxygen = find_program('doxygen', required: get_option('docs')) +dox_to_sphinx = find_program('../scripts/dox_to_sphinx.py') +sphinx_build = find_program('sphinx-build', required: get_option('docs')) + +build_docs = doxygen.found() and sphinx_build.found() +if build_docs + + # Documentation Code + + test( + 'overview_code', + executable( + 'overview_code', + files('overview_code.c'), + dependencies: [zix_dep], + c_args: c_suppressions, + ) + ) + + # Generated API Reference + + c_rst_files = files( + 'index.rst', + 'string_views.rst', + 'summary.rst', + 'using_zix.rst', + ) + + c_doc_files = c_rst_files + files( + 'conf.py', + 'overview_code.c', + ) + + foreach f : c_doc_files + configure_file(copy: true, input: f, output: '@PLAINNAME@') + endforeach + + subdir('xml') + subdir('api') + + sphinx_options = [ + '-D', 'release=@0@'.format(meson.project_version()), + '-E', + '-W', + '-a', + '-q', + ] + + doc_inputs = c_rst_files + [c_zix_rst, c_index_xml] + + custom_target( + 'singlehtml', + build_by_default: true, + command: [sphinx_build, '-M', 'singlehtml', '@OUTDIR@', '@OUTDIR@', + '-t', 'singlehtml'] + sphinx_options, + input: doc_inputs, + install: true, + install_dir: docdir / versioned_name, + output: 'singlehtml', + ) + + custom_target( + 'html', + build_by_default: true, + command: [sphinx_build, '-M', 'html', '@OUTDIR@', '@OUTDIR@', + '-t', 'html'] + sphinx_options, + input: doc_inputs, + install: true, + install_dir: docdir / versioned_name, + output: 'html', + ) + +endif diff --git a/doc/overview_code.c b/doc/overview_code.c new file mode 100644 index 0000000..1370292 --- /dev/null +++ b/doc/overview_code.c @@ -0,0 +1,51 @@ +// Copyright 2021-2022 David Robillard <d@drobilla.net> +// SPDX-License-Identifier: ISC + +/* + Example code that is included in the documentation. Code in the + documentation is included from here rather than written inline so that it can + be tested and avoid rotting. The code here doesn't make much sense, but is + written such that it at least compiles and will run without crashing. +*/ + +#include "zix/attributes.h" +#include "zix/string_view.h" + +#if defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wunused-variable" +#endif + +static void +string_views(void) +{ + const char* const string_pointer = "some string"; + + // begin make-empty-string + ZixStringView empty = zix_empty_string(); + // end make-empty-string + + // begin make-static-string + ZixStringView hello = zix_string("hello"); + // end make-static-string + + // begin measure-string + ZixStringView view = zix_string(string_pointer); + // end measure-string + + // begin make-string-view + ZixStringView slice = zix_substring(string_pointer, 4); + // end make-string-view +} + +ZIX_CONST_FUNC +int +main(void) +{ + string_views(); + return 0; +} + +#if defined(__GNUC__) +# pragma GCC diagnostic pop +#endif diff --git a/doc/string_views.rst b/doc/string_views.rst new file mode 100644 index 0000000..b6e3fd9 --- /dev/null +++ b/doc/string_views.rst @@ -0,0 +1,56 @@ +.. + Copyright 2020-2022 David Robillard <d@drobilla.net> + SPDX-License-Identifier: ISC + +String Views +============ + +.. default-domain:: c +.. highlight:: c + +For performance reasons, +many functions in zix that take a string take a :struct:`ZixStringView`, +rather than a bare pointer. +This forces code to be explicit about string measurement, +which discourages common patterns of repeated measurement of the same string. +For convenience, several macros and functions are provided for constructing string views: + +:func:`zix_empty_string` + + Constructs a view of an empty string, for example: + + .. literalinclude:: overview_code.c + :start-after: begin make-empty-string + :end-before: end make-empty-string + :dedent: 2 + +:func:`zix_string` + + Constructs a view of an arbitrary string, for example: + + .. literalinclude:: overview_code.c + :start-after: begin measure-string + :end-before: end measure-string + :dedent: 2 + + This calls ``strlen`` to measure the string. + Modern compilers should optimize this away if the parameter is a literal. + +:func:`zix_substring` + + Constructs a view of a slice of a string with an explicit length, + for example: + + .. literalinclude:: overview_code.c + :start-after: begin make-string-view + :end-before: end make-string-view + :dedent: 2 + + This can also be used to create a view of a pre-measured string. + If the length a dynamic string is already known, + this is faster than :func:`zix_string`, + since it avoids redundant measurement. + +These constructors can be used inline when passing parameters, +but if the same dynamic string is used several times, +it is better to make a string view variable to avoid redundant measurement. diff --git a/doc/summary.rst b/doc/summary.rst new file mode 100644 index 0000000..aa169df --- /dev/null +++ b/doc/summary.rst @@ -0,0 +1,5 @@ +.. + Copyright 2020-2022 David Robillard <d@drobilla.net> + SPDX-License-Identifier: ISC + +Zix is a lightweight C library of portability wrappers and data structures. diff --git a/doc/using_zix.rst b/doc/using_zix.rst new file mode 100644 index 0000000..f97ecf0 --- /dev/null +++ b/doc/using_zix.rst @@ -0,0 +1,12 @@ +.. + Copyright 2020-2022 David Robillard <d@drobilla.net> + SPDX-License-Identifier: ISC + +########## +Using Zix +########## + +.. toctree:: + + string_views + diff --git a/doc/xml/meson.build b/doc/xml/meson.build new file mode 100644 index 0000000..b614580 --- /dev/null +++ b/doc/xml/meson.build @@ -0,0 +1,19 @@ +# Copyright 2021-2022 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: 0BSD OR ISC + +config = configuration_data() +config.set('ZIX_SRCDIR', zix_src_root) +config.set('DOX_OUTPUT', meson.current_build_dir() / '..') + +c_doxyfile = configure_file( + configuration: config, + input: files('../Doxyfile.in'), + output: 'Doxyfile', +) + +c_index_xml = custom_target( + 'index.xml', + command: [doxygen, '@INPUT0@'], + input: [c_doxyfile] + c_headers, + output: 'index.xml', +) diff --git a/meson.build b/meson.build index 5d5eaa7..0e0afac 100644 --- a/meson.build +++ b/meson.build @@ -12,6 +12,7 @@ project('zix', ['c'], 'cpp_std=c++17', ]) +zix_src_root = meson.current_source_dir() major_version = meson.project_version().split('.')[0] version_suffix = '-@0@'.format(major_version) versioned_name = 'zix' + version_suffix @@ -521,8 +522,16 @@ if not get_option('benchmarks').disabled() endif endif +############################# +# Scripts and Documentation # +############################# + subdir('scripts') +if not get_option('docs').disabled() + subdir('doc') +endif + if not meson.is_subproject() summary('Benchmarks', build_benchmarks, bool_yn: true) summary('Tests', not get_option('tests').disabled(), bool_yn: true) diff --git a/meson_options.txt b/meson_options.txt index bc25d2f..476b3d5 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -4,6 +4,9 @@ option('benchmarks', type: 'feature', value: 'auto', yield: true, option('checks', type: 'boolean', value: true, yield: true, description: 'Check for features with the build system') +option('docs', type: 'feature', value: 'auto', yield: true, + description: 'Build documentation') + option('posix', type: 'feature', value: 'auto', yield: true, description: 'Use POSIX system facilities') diff --git a/scripts/dox_to_sphinx.py b/scripts/dox_to_sphinx.py new file mode 100755 index 0000000..49fbdfd --- /dev/null +++ b/scripts/dox_to_sphinx.py @@ -0,0 +1,698 @@ +#!/usr/bin/env python3 + +# Copyright 2020 David Robillard <d@drobilla.net> +# SPDX-License-Identifier: ISC + +""" +Write Sphinx markup from Doxygen XML. + +Takes a path to a directory of XML generated by Doxygen, and emits a directory +with a reStructuredText file for every documented symbol. +""" + +import argparse +import os +import re +import sys +import textwrap +import xml.etree.ElementTree + +__author__ = "David Robillard" +__date__ = "2020-11-18" +__email__ = "d@drobilla.net" +__license__ = "ISC" +__version__ = __date__.replace("-", ".") + + +def load_index(index_path): + """ + Load the index from XML. + + :returns: A dictionary from ID to skeleton records with basic information + for every documented entity. Some records have an ``xml_filename`` key + with the filename of a definition file. These files will be loaded later + to flesh out the records in the index. + """ + + root = xml.etree.ElementTree.parse(index_path).getroot() + index = {} + + for compound in root: + compound_id = compound.get("refid") + compound_kind = compound.get("kind") + compound_name = compound.find("name").text + if compound_kind in ["dir", "file", "page"]: + continue + + # Add record for compound (compounds appear only once in the index) + assert compound_id not in index + index[compound_id] = { + "kind": compound_kind, + "name": compound_name, + "xml_filename": compound_id + ".xml", + "children": [], + } + + name_prefix = ( + ("%s::" % compound_name) if compound_kind == "namespace" else "" + ) + + for child in compound.findall("member"): + if child.get("refid") in index: + assert compound_kind == "group" + continue + + # Everything has a kind and a name + child_record = { + "kind": child.get("kind"), + "name": name_prefix + child.find("name").text, + } + + if child.get("kind") == "enum": + # Enums are not compounds, but we want to resolve the parent of + # their values so they are not written as top level documents + child_record["children"] = [] + + if child.get("kind") == "enumvalue": + # Remove namespace prefix + child_record["name"] = child.find("name").text + + if child.get("kind") == "variable": + if child_record["name"][0] == "@": + # Remove placeholder name from anonymous struct or union + child_record["name"] = "" + else: + # Remove namespace prefix + child_record["name"] = child.find("name").text + + index[child.get("refid")] = child_record + + return index + + +def resolve_index(index, root): + """ + Walk a definition document and extend the index for linking. + + This does two things: sets the "parent" and "children" fields of all + applicable records, and sets the "strong" field of enums so that the + correct Sphinx role can be used when referring to them. + """ + + def add_child(index, parent_id, child_id): + parent = index[parent_id] + child = index[child_id] + + if child["kind"] == "enumvalue": + assert parent["kind"] == "enum" + assert "parent" not in child or child["parent"] == parent_id + child["parent"] = parent_id + + else: + if parent["kind"] in ["class", "struct", "union"]: + assert "parent" not in child or child["parent"] == parent_id + child["parent"] = parent_id + + if child_id not in parent["children"]: + parent["children"] += [child_id] + + compound = root.find("compounddef") + compound_kind = compound.get("kind") + + if compound_kind == "group": + for subgroup in compound.findall("innergroup"): + add_child(index, compound.get("id"), subgroup.get("refid")) + + for klass in compound.findall("innerclass"): + add_child(index, compound.get("id"), klass.get("refid")) + + for section in compound.findall("sectiondef"): + if section.get("kind").startswith("private"): + for member in section.findall("memberdef"): + if member.get("id") in index: + del index[member.get("id")] + else: + for member in section.findall("memberdef"): + if member.find("definition") is not None: + definition = plain_text(member.find("definition")) + + # Skip constructors, which Doxygen also lists as functions + # in groups for some reason. Unfortunately the only way I + # can see to distinguish them is to assume things like + # symbol::Symbol::Symbol are constructors. + if re.match( + "[_0-9A-Za-z]*::[_0-9A-Za-z]*::[_0-9A-Za-z]*", + definition, + ): + continue + + member_id = member.get("id") + add_child(index, compound.get("id"), member_id) + + if member.get("kind") == "enum": + index[member_id]["strong"] = member.get("strong") == "yes" + for value in member.findall("enumvalue"): + add_child(index, member_id, value.get("id")) + + +def sphinx_role(record, lang): + """ + Return the Sphinx role used for a record. + + This is used for the description directive like ".. c:function::", and + links like ":c:func:`foo`. + """ + + kind = record["kind"] + + if kind in ["class", "function", "namespace", "struct", "union"]: + return lang + ":" + kind + + if kind == "define": + return "c:macro" + + if kind == "group": + return "ref" + + if kind == "enum": + return lang + (":enum-class" if record["strong"] else ":enum") + + if kind == "typedef": + return lang + ":type" + + if kind == "enumvalue": + return lang + ":enumerator" + + if kind == "variable": + return lang + (":member" if "parent" in record else ":var") + + raise RuntimeError("No known role for kind '%s'" % kind) + + +def child_identifier(lang, parent_name, child_name): + """ + Return the identifier for an enum value or struct member. + + Sphinx, for some reason, uses a different syntax for this in C and C++. + """ + + separator = "::" if lang == "cpp" else "." + + return "%s%s%s" % (parent_name, separator, child_name) + + +def link_markup(index, lang, refid): + """Return a Sphinx link for a Doxygen reference.""" + + record = index[refid] + kind, name = record["kind"], record["name"] + role = sphinx_role(record, lang) + + if kind in ["class", "define", "enum", "struct", "typedef", "union"]: + return ":%s:`%s`" % (role, name) + + if kind == "group": + return ":ref:`%s`" % name + + if kind == "function": + return ":%s:func:`%s`" % (lang, name) + + if kind == "enumvalue": + parent_name = index[record["parent"]]["name"] + return ":%s:`%s`" % (role, child_identifier(lang, parent_name, name)) + + if kind == "variable": + if "parent" not in record: + return ":%s:var:`%s`" % (lang, name) + + parent_name = index[record["parent"]]["name"] + return ":%s:`%s`" % (role, child_identifier(lang, parent_name, name)) + + raise RuntimeError("Unknown link target kind: %s" % kind) + + +def indent(markup, depth): + """ + Indent markup to a depth level. + + Like textwrap.indent() but takes an integer and works in reST indentation + levels for clarity." + """ + + return textwrap.indent(markup, " " * depth) + + +def heading(text, level): + """ + Return a ReST heading at a given level. + + Follows the style in the Python documentation guide, see + <https://devguide.python.org/documenting/#sections>. + """ + + assert 1 <= level <= 6 + + chars = ("#", "*", "=", "-", "^", '"') + line = chars[level] * len(text) + + return "%s%s\n%s\n\n" % (line + "\n" if level < 3 else "", text, line) + + +def dox_to_rst(index, lang, node): + """ + Convert documentation commands (docCmdGroup) to Sphinx markup. + + This is used to convert the content of descriptions in the documentation. + It recursively parses all children tags and raises a RuntimeError if any + unknown tag is encountered. + """ + + def field_value(markup): + """Return a value for a field as a single line or indented block.""" + if "\n" in markup.strip(): + return "\n" + indent(markup, 1) + + return " " + markup.strip() + + if node.tag == "emphasis": + return "*%s*" % plain_text(node) + + if node.tag == "lsquo": + return "‘" + + if node.tag == "rsquo": + return "’" + + if node.tag == "computeroutput": + return "``%s``" % plain_text(node) + + if node.tag == "itemizedlist": + markup = "" + for item in node.findall("listitem"): + assert len(item) == 1 + markup += "\n- %s" % dox_to_rst(index, lang, item[0]) + + return markup + + if node.tag == "para": + markup = node.text if node.text is not None else "" + for child in node: + markup += dox_to_rst(index, lang, child) + markup += child.tail if child.tail is not None else "" + + return markup.strip() + "\n\n" + + if node.tag == "parameterlist": + markup = "" + for item in node.findall("parameteritem"): + name = item.find("parameternamelist/parametername") + description = item.find("parameterdescription") + assert len(description) == 1 + markup += "\n\n:param %s:%s" % ( + name.text, + field_value(dox_to_rst(index, lang, description[0])), + ) + + return markup + "\n" + + if node.tag == "programlisting": + return "\n.. code-block:: %s\n\n%s" % ( + lang, + indent(plain_text(node), 1), + ) + + if node.tag == "ref": + refid = node.get("refid") + if refid not in index: + sys.stderr.write("warning: Unresolved link: %s\n" % refid) + return node.text + + assert len(node) == 0 + assert len(link_markup(index, lang, refid)) > 0 + return link_markup(index, lang, refid) + + if node.tag == "simplesect": + assert len(node) == 1 + + if node.get("kind") == "return": + return "\n:returns:" + field_value( + dox_to_rst(index, lang, node[0]) + ) + + if node.get("kind") == "see": + return dox_to_rst(index, lang, node[0]) + + raise RuntimeError("Unknown simplesect kind: %s" % node.get("kind")) + + if node.tag == "ulink": + return "`%s <%s>`_" % (node.text, node.get("url")) + + raise RuntimeError("Unknown documentation command: %s" % node.tag) + + +def description_markup(index, lang, node): + """Return the markup for a brief or detailed description.""" + + assert node.tag == "briefdescription" or node.tag == "detaileddescription" + assert not (node.tag == "briefdescription" and len(node) > 1) + assert len(node.text.strip()) == 0 + + return "".join([dox_to_rst(index, lang, child) for child in node]).strip() + + +def set_descriptions(index, lang, definition, record): + """Set a record's brief/detailed descriptions from the XML definition.""" + + for tag in ["briefdescription", "detaileddescription"]: + node = definition.find(tag) + if node is not None: + record[tag] = description_markup(index, lang, node) + + +def set_template_params(node, record): + """Set a record's template_params from the XML definition.""" + + template_param_list = node.find("templateparamlist") + if template_param_list is not None: + params = [] + for param in template_param_list.findall("param"): + if param.find("declname") is not None: + # Value parameter + type_text = plain_text(param.find("type")) + name_text = plain_text(param.find("declname")) + + params += ["%s %s" % (type_text, name_text)] + else: + # Type parameter + params += ["%s" % (plain_text(param.find("type")))] + + record["template_params"] = "%s" % ", ".join(params) + + +def plain_text(node): + """ + Return the plain text of a node with all tags ignored. + + This is needed where Doxygen may include refs but Sphinx needs plain text + because it parses things itself to generate links. + """ + + if node.tag == "sp": + markup = " " + elif node.text is not None: + markup = node.text + else: + markup = "" + + for child in node: + markup += plain_text(child) + markup += child.tail if child.tail is not None else "" + + return markup + + +def local_name(name): + """Return a name with all namespace prefixes stripped.""" + + try: + sep_end = name.rindex("::") + 2 + return name[sep_end:] + except ValueError: + return name + + +def read_definition_doc(index, lang, root): + """Walk a definition document and update described records in the index.""" + + # Set descriptions for the compound itself + compound = root.find("compounddef") + compound_record = index[compound.get("id")] + set_descriptions(index, lang, compound, compound_record) + set_template_params(compound, compound_record) + + if compound.find("title") is not None: + compound_record["title"] = compound.find("title").text.strip() + + # Set documentation for all children + for section in compound.findall("sectiondef"): + if section.get("kind").startswith("private"): + continue + + for member in section.findall("memberdef"): + kind = member.get("kind") + record = index[member.get("id")] + set_descriptions(index, lang, member, record) + set_template_params(member, record) + + if compound.get("kind") in ["class", "struct", "union"]: + assert kind in ["function", "typedef", "variable"] + record["type"] = plain_text(member.find("type")) + + if kind == "define": + if member.find("param") is not None: + param_names = [] + for param in member.findall("param"): + defname = param.find("defname") + param_names += ( + [defname.text] if defname is not None else [] + ) + + record["prototype"] = "%s(%s)" % ( + record["name"], + ", ".join(param_names), + ) + + elif kind == "enum": + for value in member.findall("enumvalue"): + set_descriptions( + index, lang, value, index[value.get("id")] + ) + + elif kind == "function": + record["prototype"] = "%s %s%s" % ( + plain_text(member.find("type")), + member.find("name").text, + member.find("argsstring").text, + ) + + elif kind == "typedef": + name = local_name(record["name"]) + args_text = member.find("argsstring").text + target_text = plain_text(member.find("type")) + if args_text is not None: # Function pointer + assert target_text[-2:] == "(*" and args_text[0] == ")" + record["type"] = target_text + args_text + record["definition"] = target_text + name + args_text + else: # Normal named typedef + assert target_text is not None + record["type"] = target_text + if member.find("definition").text.startswith("using"): + record["definition"] = "%s = %s" % ( + name, + target_text, + ) + else: + record["definition"] = "%s %s" % ( + target_text, + name, + ) + + elif kind == "variable": + record["type"] = plain_text(member.find("type")) + record["name"] = plain_text(member.find("name")) + record["definition"] = plain_text(member.find("definition")) + + +def declaration_string(record): + """ + Return the string that describes a declaration. + + This is what follows the directive, and is in C/C++ syntax, except without + keywords like "typedef" and "using" as expected by Sphinx. For example, + "struct ThingImpl Thing" or "void run(int value)". + """ + + kind = record["kind"] + result = "" + + if "template_params" in record: + result = "template <%s> " % record["template_params"] + + if kind == "function": + result += record["prototype"] + elif kind == "typedef": + result += record["definition"] + elif kind == "variable": + if "type" in record and "name" in record: + result += "%s %s" % (record["type"], local_name(record["name"])) + else: + result += record["definition"] + elif "type" in record: + result += "%s %s" % (record["type"], local_name(record["name"])) + else: + result += local_name(record["name"]) + + return result + + +def document_markup(index, lang, record): + """Return the complete document that describes some documented entity.""" + + kind = record["kind"] + role = sphinx_role(record, lang) + name = record["name"] + markup = "" + + if name != local_name(name): + sep = name.rindex("::") + markup += ".. cpp:namespace:: %s\n\n" % name[0:sep] + + # Write top-level directive + markup += ".. %s:: %s\n" % (role, declaration_string(record)) + + # Write main description blurb + markup += "\n" + indent(record["briefdescription"] + "\n", 1) + if len(record["detaileddescription"]) > 0: + markup += "\n" + indent(record["detaileddescription"], 1) + "\n" + + assert ( + kind in ["class", "enum", "namespace", "struct", "union"] + or "children" not in record + ) + + # Sphinx C++ namespaces work by setting a scope, they have no content + child_indent = 0 if kind == "namespace" else 1 + + # Write inline children if applicable + markup += "\n" if "children" in record else "" + for child_id in record.get("children", []): + child_record = index[child_id] + child_role = sphinx_role(child_record, lang) + + if not child_record["name"]: + continue # Skip anonymous union member + + child_header = ".. %s:: %s\n\n" % ( + child_role, + declaration_string(child_record), + ) + + markup += "\n" + markup += indent(child_header, child_indent) + markup += indent(child_record["briefdescription"], child_indent + 1) + markup += indent(child_record["detaileddescription"], child_indent + 1) + markup += "\n" + + return markup + + +def symbol_filename(name): + """Adapt the name of a symbol to be suitable for use as a filename.""" + + return name.replace("::", "__") + + +def emit_groups(index, lang, output_dir, force): + """Write a description file for every group documented in the index.""" + + for record in index.values(): + if record["kind"] != "group": + continue + + name = record["name"] + filename = os.path.join(output_dir, "%s.rst" % name) + if not force and os.path.exists(filename): + raise FileExistsError("File already exists: '%s'" % filename) + + with open(filename, "w") as rst: + rst.write(".. _%s:\n\n" % name) + rst.write(heading(record["title"], 1)) + + # Get all child group and symbol names + child_groups = {} + child_symbols = {} + for child_id in record["children"]: + child = index[child_id] + assert child["name"][0] != "@" + if child["kind"] == "group": + child_groups[child["name"]] = child + else: + child_symbols[child["name"]] = child + + # Emit description (document body) + if len(record["briefdescription"]) > 0: + rst.write(record["briefdescription"] + "\n\n") + if len(record["detaileddescription"]) > 0: + rst.write(record["detaileddescription"] + "\n\n") + + if len(child_groups) > 0: + # Emit TOC for child groups + rst.write(".. toctree::\n\n") + for name, group in child_groups.items(): + rst.write(indent(group["name"], 1) + "\n") + + # Emit symbols in sorted order + for name, symbol in child_symbols.items(): + rst.write("\n") + rst.write(document_markup(index, lang, symbol)) + rst.write("\n") + + +def run(index_xml_path, output_dir, language, force): + """Write a directory of Sphinx files from a Doxygen XML directory.""" + + # Build skeleton index from index.xml + xml_dir = os.path.dirname(index_xml_path) + index = load_index(index_xml_path) + + # Load all definition documents + definition_docs = [] + for record in index.values(): + if "xml_filename" in record: + xml_path = os.path.join(xml_dir, record["xml_filename"]) + definition_docs += [xml.etree.ElementTree.parse(xml_path)] + + # Do an initial pass of the definition documents to resolve the index + for root in definition_docs: + resolve_index(index, root) + + # Finally read the documentation from definition documents + for root in definition_docs: + read_definition_doc(index, language, root) + + # Create output directory + try: + os.makedirs(output_dir) + except OSError: + pass + + # Emit output files + emit_groups(index, language, output_dir, force) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser( + usage="%(prog)s [OPTION]... XML_DIR OUTPUT_DIR", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + ap.add_argument( + "-f", + "--force", + action="store_true", + help="overwrite files", + ) + + ap.add_argument( + "-l", + "--language", + default="c", + choices=["c", "cpp"], + help="language domain for output", + ) + + ap.add_argument("index_xml_path", help="path index.xml from Doxygen") + ap.add_argument("output_dir", help="output directory") + + run(**vars(ap.parse_args(sys.argv[1:]))) diff --git a/scripts/meson.build b/scripts/meson.build index f3f347e..5b04f99 100644 --- a/scripts/meson.build +++ b/scripts/meson.build @@ -6,22 +6,20 @@ if get_option('strict') and not meson.is_subproject() pylint = find_program('pylint', required: get_option('tests')) black = find_program('black', required: get_option('tests')) - # Scripts that pass with everything including pylint - scripts = files( - 'benchmark.py', - 'plot.py', - ) + strict_scripts = files('benchmark.py', 'plot.py') + linty_scripts = files('dox_to_sphinx.py') + all_scripts = strict_scripts + linty_scripts if is_variable('black') and black.found() black_opts = ['-l', '79', '-q', '--check'] - test('black', black, args: black_opts + scripts, suite: 'scripts') + test('black', black, args: black_opts + all_scripts, suite: 'scripts') endif if is_variable('flake8') and flake8.found() - test('flake8', flake8, args: scripts, suite: 'scripts') + test('flake8', flake8, args: all_scripts, suite: 'scripts') endif if is_variable('pylint') and pylint.found() - test('pylint', pylint, args: scripts, suite: 'scripts') + test('pylint', pylint, args: strict_scripts, suite: 'scripts') endif endif |