Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/scripts/check-cpp-docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Require bilingual documentation on public C++ declarations."""

from pathlib import Path
import re
import sys


def declaration_kind(line: str) -> str | None:
stripped = line.strip()
if re.match(r"(?:struct|class)\s+\w+", stripped):
return "type"
if re.match(r"concept\s+\w+\s*=", stripped):
return "type"
if re.match(r"using\s+\w+\s*=", stripped):
return "alias"
if stripped.startswith("virtual "):
return "method"
if stripped.startswith("#define "):
return "macro"
if re.match(r"(?:THIS_REFERENCE_WRAPPER_METHODS|VARIABLE_WRAPPER_METHODS|USE_ALL_BASE_CONSTRUCTORS)\(", stripped):
return "generated members"
if re.match(r"ExtendedReferenceBase\(", stripped):
return "constructor"
if re.match(r"TExtendable&?\s+extendable;", stripped):
return "field"
return None


def documentation_before(lines: list[str], index: int) -> tuple[str, str]:
previous = index - 1
template = ""
if previous >= 0 and lines[previous].strip().startswith("template <"):
template = lines[previous].strip()
previous -= 1
comment = []
while previous >= 0 and lines[previous].lstrip().startswith("///"):
comment.append(lines[previous].strip())
previous -= 1
return "\n".join(reversed(comment)), template


def summary_errors(comment: str, label: str) -> list[str]:
errors = []
if "<summary>" not in comment or "</summary>" not in comment:
return [f"{label} lacks a documentation summary"]
paragraphs = re.findall(r"<para>(.*?)</para>", comment)
if not any(re.search(r"[A-Za-z]", paragraph) for paragraph in paragraphs) or not any(
re.search(r"[А-Яа-яЁё]", paragraph) for paragraph in paragraphs
):
errors.append(f"{label} lacks English and Russian paragraphs")
return errors


def template_errors(comment: str, template: str, label: str) -> list[str]:
parameters = dict.fromkeys(re.findall(r"\bT(?:[A-Z]\w*)?\b", template))
return [
f"{label} lacks documentation for {parameter}"
for parameter in parameters
if f'<typeparam name="{parameter}">' not in comment
]


def callable_errors(comment: str, signature: str, kind: str, label: str) -> list[str]:
errors = []
arguments = re.search(r"\((.*?)\)", signature)
if arguments:
for argument in arguments.group(1).split(","):
name = re.search(r"(\w+)$", argument.strip())
if name and f'<param name="{name.group(1)}">' not in comment:
errors.append(f"{label} lacks documentation for argument {name.group(1)}")
if kind == "method" and not ("virtual void " in signature or "virtual ~" in signature) and "<returns>" not in comment:
errors.append(f"{label} lacks return documentation")
return errors


def declaration_errors(lines: list[str], index: int, kind: str, path: Path) -> list[str]:
comment, template = documentation_before(lines, index)
label = f"{path}:{index + 1}: {kind}"
errors = summary_errors(comment, label)
if kind == "type":
errors.extend(template_errors(comment, template, label))
if kind in {"method", "constructor"}:
errors.extend(callable_errors(comment, lines[index].strip(), kind, label))
return errors


def validate_header(path: Path) -> list[str]:
lines = path.read_text(encoding="utf-8-sig").splitlines()
errors = []
inside_internal = False
for index, line in enumerate(lines):
if "namespace Internal {" in line:
inside_internal = True
if "} // namespace Internal" in line:
inside_internal = False
continue
if inside_internal:
continue
kind = declaration_kind(line)
if kind is not None:
errors.extend(declaration_errors(lines, index, kind, path))
return errors


def main() -> int:
root = Path(__file__).resolve().parents[2] / "cpp" / "Platform.Interfaces"
errors = [error for path in sorted(root.glob("*.h")) for error in validate_header(path)]
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(f"Bilingual documentation covers the public declarations in {len(list(root.glob('*.h')))} C++ headers.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
53 changes: 53 additions & 0 deletions .github/scripts/check-cpp-docs.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Regression checks for C++ documentation coverage."""

from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest


script = Path(__file__).with_name("check-cpp-docs.py")
spec = spec_from_file_location("check_cpp_docs", script)
module = module_from_spec(spec)
spec.loader.exec_module(module)


class DocumentationCoverageTests(unittest.TestCase):
def validate(self, source: str) -> list[str]:
with TemporaryDirectory() as directory:
path = Path(directory) / "Test.h"
path.write_text(source)
return module.validate_header(path)

def test_undocumented_concept_is_rejected(self):
self.assertTrue(self.validate("template <typename TSelf>\nconcept CExample = true;\n"))

def test_undocumented_primary_template_is_rejected(self):
self.assertTrue(self.validate("template <typename...>\nstruct IExample;\n"))

def test_bilingual_concept_with_type_parameter_is_accepted(self):
source = """/// <summary>
/// <para>Checks an example.</para>
/// <para>Проверяет пример.</para>
/// </summary>
/// <typeparam name="TSelf">The type.</typeparam>
template <typename TSelf>
concept CExample = true;
"""
self.assertEqual(self.validate(source), [])

def test_undocumented_public_member_is_rejected(self):
source = """/// <summary>
/// <para>A holder.</para>
/// <para>Хранилище.</para>
/// </summary>
struct Holder {
using Value = int;
};
"""
self.assertTrue(any("alias" in error for error in self.validate(source)))


if __name__ == "__main__":
unittest.main()
59 changes: 59 additions & 0 deletions .github/scripts/check-csharp-docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Require English and Russian paragraphs in generated C# API documentation."""

from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET


DOCUMENTATION_TAGS = {"summary", "remarks", "typeparam", "param", "returns", "value", "example", "exception"}


def has_bilingual_paragraphs(element: ET.Element) -> bool:
paragraphs = ["".join(paragraph.itertext()) for paragraph in element.findall("para")]
return any(re.search(r"[A-Za-z]", paragraph) for paragraph in paragraphs) and any(
re.search(r"[А-Яа-яЁё]", paragraph) for paragraph in paragraphs
)


def validate_xml(path: Path) -> list[str]:
root = ET.parse(path).getroot()
members = root.findall("./members/member")
if not members:
return [f"{path}: generated XML contains no documented API members"]

errors = []
for member in members:
name = member.get("name", "unnamed member")
if member.find("summary") is None:
errors.append(f"{name}: missing summary")
for section in member:
if section.tag not in DOCUMENTATION_TAGS:
continue
label = f"{section.tag} {section.get('name', '')}".strip()
if not has_bilingual_paragraphs(section):
errors.append(f"{name}: {label} lacks English and Russian paragraphs")
return errors


def main() -> int:
if len(sys.argv) != 2:
print("Usage: check-csharp-docs.py XML_FILE", file=sys.stderr)
return 2
path = Path(sys.argv[1])
try:
errors = validate_xml(path)
except (OSError, ET.ParseError) as error:
print(f"{path}: {error}", file=sys.stderr)
return 1
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
count = len(ET.parse(path).getroot().findall("./members/member"))
print(f"Validated bilingual XML documentation for {count} C# API members.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
48 changes: 48 additions & 0 deletions .github/scripts/check-csharp-docs.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Regression checks for generated C# XML documentation."""

from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest


script = Path(__file__).with_name("check-csharp-docs.py")
spec = spec_from_file_location("check_csharp_docs", script)
module = module_from_spec(spec)
spec.loader.exec_module(module)


class DocumentationCoverageTests(unittest.TestCase):
def validate(self, content: str) -> list[str]:
with TemporaryDirectory() as directory:
path = Path(directory) / "Platform.Interfaces.xml"
path.write_text(content, encoding="utf-8")
return module.validate_xml(path)

def test_bilingual_member_and_parameter_are_accepted(self):
xml = """<doc><members><member name="M:Example.Run(System.String)">
<summary><para>Runs.</para><para>Запускает.</para></summary>
<param name="input"><para>Input.</para><para>Ввод.</para></param>
</member></members></doc>"""
self.assertEqual(self.validate(xml), [])

def test_missing_russian_summary_is_rejected(self):
xml = """<doc><members><member name="T:Example">
<summary><para>An example.</para></summary>
</member></members></doc>"""
self.assertTrue(any("summary" in error for error in self.validate(xml)))

def test_missing_english_parameter_is_rejected(self):
xml = """<doc><members><member name="M:Example.Run(System.String)">
<summary><para>Runs.</para><para>Запускает.</para></summary>
<param name="input"><para>Ввод.</para></param>
</member></members></doc>"""
self.assertTrue(any("param input" in error for error in self.validate(xml)))

def test_empty_member_list_is_rejected(self):
self.assertTrue(self.validate("<doc><members /></doc>"))


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion .github/scripts/validate-csharp-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ if [[ ${#symbol_packages[@]} -ne 0 ]]; then
fi

package_contents=$(unzip -Z1 "${packages[0]}")
for expected_file in README.md icon.png lib/net8.0/Platform.Interfaces.dll; do
for expected_file in README.md icon.png lib/net8.0/Platform.Interfaces.dll lib/net8.0/Platform.Interfaces.xml; do
if ! grep -Fxq "$expected_file" <<< "$package_contents"; then
echo "${packages[0]} is missing $expected_file." >&2
exit 1
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/cpp-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: C++ documentation

on:
push:
branches: [main]
paths:
- 'cpp/Platform.Interfaces/**'
- 'cpp/Doxyfile'
- '.github/scripts/check-cpp-docs.py'
- '.github/scripts/check-cpp-docs.test.py'
- '.github/workflows/cpp-docs.yml'
pull_request:
branches: [main]
paths:
- 'cpp/Platform.Interfaces/**'
- 'cpp/Doxyfile'
- '.github/scripts/check-cpp-docs.py'
- '.github/scripts/check-cpp-docs.test.py'
- '.github/workflows/cpp-docs.yml'

jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install Doxygen
run: |
sudo apt-get update
sudo apt-get install -y doxygen
- name: Check documentation coverage
run: |
python3 .github/scripts/check-cpp-docs.test.py
python3 .github/scripts/check-cpp-docs.py
- name: Generate documentation
run: |
doxygen cpp/Doxyfile
test -s cpp/docs/html/index.html
test -s cpp/docs/xml/index.xml
- name: Upload HTML and XML documentation
uses: actions/upload-artifact@v4
with:
name: cpp-api-documentation
path: |
cpp/docs/html
cpp/docs/xml
5 changes: 5 additions & 0 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ jobs:
- name: Validate tests and NuGet package
run: ../.github/scripts/validate-csharp-package.sh

- name: Validate bilingual C# API documentation
run: |
python3 ../.github/scripts/check-csharp-docs.test.py
python3 ../.github/scripts/check-csharp-docs.py Platform.Interfaces/bin/Release/net8/Platform.Interfaces.xml

- name: Test C# workflow safeguards
run: node --test ../.github/scripts/*csharp*.test.mjs

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ ASALocalRun/
# Generated DocFX site
csharp/_site/

# Generated Doxygen site
cpp/docs/

# NVidia Nsight GPU debugger configuration file
*.nvuser

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ NuGet package: [Platform.Interfaces](https://www.nuget.org/packages/Platform.Int

[PDF file](https://linksplatform.github.io/Interfaces/csharp/Platform.Interfaces.pdf) with code for e-readers.

[API documentation PDF](https://linksplatform.github.io/Interfaces/csharp/Platform.Interfaces.Documentation.pdf) generated by DocFX.
The [C++ documentation workflow](https://github.com/linksplatform/Interfaces/actions/workflows/cpp-docs.yml) checks bilingual English/Russian comments and generates an HTML and XML API reference as a downloadable artifact. Run `doxygen cpp/Doxyfile` from the repository root to build it locally.

The [C# workflow](https://github.com/linksplatform/Interfaces/actions/workflows/csharp.yml) checks bilingual XML comments and publishes the DocFX site, including the [API documentation PDF](https://linksplatform.github.io/Interfaces/csharp/Platform.Interfaces.Documentation.pdf).

## Dependent libraries
* [Platform.Collections](https://github.com/linksplatform/Collections)
Expand Down
Loading
Loading