blob: 37f801a78925bb87a174fab5f3840f657969628d [file]
#!/usr/bin/env python3
"""Generate CMake presets documentation (reST) and JSON schema.
The documentation and JSON schema for CMake presets are both generated from
Help/manual/presets/schema.yaml, which describes the schema in a way that
allows both documentation forms to be combined in a single file, and also makes
it much easier to manage version revisions (compared to editing the JSON schema
'by hand').
All input and output files are expected to be in known locations relative to
this script.
Usage: python3 regenerate-presets.py
"""
import json
import re
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Self
import yaml
LATEST = 1
TYPES = {}
REFS = {}
PRESETS_REL = 'Help/manual/presets'
SCHEMA_YAML_FILENAME = 'schema.yaml'
SCHEMA_JSON_FILENAME = 'schema.json'
WORKSPACE = Path(__file__).parent.parent.parent.absolute()
PRESETS = WORKSPACE / PRESETS_REL
DIAGNOSTICS = WORKSPACE / 'Source/cmDiagnostics.h'
RST_BANNER = f"""\
.. This file was generated by {Path(__file__).relative_to(WORKSPACE)}
from {PRESETS_REL}/{SCHEMA_YAML_FILENAME}. Do not edit.
"""
DIAGNOSTIC_TABLE_MACRO = 'CM_FOR_EACH_DIAGNOSTIC_TABLE'
CONFIGURE_PRESET_PROPERTIES_PATH = (
'properties', 'configurePresets', 'items', 'properties'
)
WARNING_DESCRIPTION = (
'An optional boolean. '
'Equivalent to passing -W{c} or -Wno-{c} on the command line. '
'This may not be set to false if errors.{p} is set to true.'
)
WARNING_SPHINX_DESCRIPTION = """
An optional boolean. Equivalent to passing :option:`-W{c} <cmake -W>` or
:option:`-Wno-{c} <cmake -Wno->` on the command line.
This may not be set to ``false`` if ``errors.{p}`` is set to ``true``.
""".strip()
ERROR_DESCRIPTION = (
'An optional boolean. '
'Equivalent to passing -Werror={c} or -Wno-error={c} on the command line. '
'This may not be set to true if warnings.{p} is set to false.'
)
ERROR_SPHINX_DESCRIPTION = """
An optional boolean. Equivalent to passing :cmake-option:`-Werror={c}` or
:cmake-option:`-Wno-error={c}` on the command line.
This may not be set to ``true`` if ``warnings.{p}`` is set to ``false``.
""".strip()
# =============================================================================
@dataclass
class Diagnostic:
presetName: str
cliName: str
since: int
# -------------------------------------------------------------------------
def format(self, template: str) -> str:
return template.format(c=self.cliName, p=self.presetName)
# =============================================================================
class Value:
# -------------------------------------------------------------------------
def __init__(self, data: list[dict[str, Any]]):
self.data = data
self.spans = None
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, 'Object']:
return {}
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
self.spans = [(since, until)]
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
pass
# -------------------------------------------------------------------------
def defs(self) -> dict[str, Any]:
return {}
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
return deepcopy(self.data)
# =============================================================================
class Object(Value):
# -------------------------------------------------------------------------
def __init__(self, data: dict[str, Any], name: str,
label: str | None = None):
# Prevent data sharing from modifying unrelated parts of the schema as
# we pick apart our section.
data = deepcopy(data)
self.name = data.pop('id', name)
self.label = label
self.properties = {}
for n, p in data.pop('properties').items():
pl = '.'.join([label, n]) if label is not None else n
self.properties[n] = Property(p, n, pl)
if len(self.properties):
self.properties[CommentProperty.name] = CommentProperty()
ap = data.pop('additionalProperties', None)
if ap is not None:
self.additionalProperties = buildValue(ap, self.name, self.label)
else:
self.additionalProperties = None
super().__init__(data)
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, Self]:
out = {self.name: self}
for p in self.properties.values():
out.update(p.objects)
if self.additionalProperties is not None:
out.update(self.additionalProperties.objects)
return out
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
spans = [[(since, until)]]
for p in self.properties.values():
p.updateSpans(since, until)
spans.append(p.spans)
if self.additionalProperties is not None:
self.additionalProperties.updateSpans(since, until)
spans.append(self.additionalProperties.spans)
self.spans = combineSpans(*spans)
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
for p in self.properties.values():
p.updateRefs(objects)
if self.additionalProperties is not None:
self.additionalProperties.updateRefs(objects)
# -------------------------------------------------------------------------
def doc(self, since: int, until: int) -> str | None:
since = max(since, self.spans[0][0])
until = min(until, self.spans[-1][1])
out = []
for _, p in self.properties.items():
content = p.doc(since, until)
if not content:
continue
out.append(content)
return '\n\n'.join(out) if len(out) else None
# -------------------------------------------------------------------------
def defs(self, exclude: set[str] = {}) -> dict[str, Any]:
out = {}
for n, p in self.properties.items():
if n in exclude:
continue
for s in p.spans:
r = f'{p.label}@{spanRef(*s)}'
out[r] = p.schema(s[0])
out.update(p.defs())
if self.additionalProperties is not None:
out.update(self.additionalProperties.defs())
return out
# -------------------------------------------------------------------------
def schema(self, version: int, inline: set[str] = {}) -> dict[str, Any]:
out = deepcopy(self.data)
props = {}
required = []
for n, p in self.properties.items():
if n in inline:
props[n] = p.schema(version)
else:
r = p.ref(version)
if r is not None:
props[n] = r
if p.required:
required.append(n)
out['properties'] = props
if len(required):
out['required'] = required
if self.additionalProperties is not None:
ap = self.additionalProperties.schema(version)
out['additionalProperties'] = ap
return out
# =============================================================================
class Ref(Value):
# -------------------------------------------------------------------------
def __init__(self, data: list[dict[str, Any]]):
self.target = data.pop('target')
self.targetSpans = None
super().__init__(data)
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
if self.spans is None:
super().updateSpans(since, until)
self.spans = trimSpans(self.spans, since, until)
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
global REFS
t = objects.get(self.target)
if t is not None:
since = self.spans[0][0]
until = self.spans[-1][1]
self.spans = trimSpans(t.spans, since, until)
self.targetSpans = t.spans
rs = REFS.get(self.target, (since, until))
REFS[self.target] = (min(since, rs[0]), max(until, rs[1]))
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
ts = trimSpans(self.targetSpans, *REFS[self.target])
s = findSpan(ts, version)
if s is not None:
return {
'$ref': f'#/definitions/{self.target}@{spanRef(*s)}',
}
return {}
# =============================================================================
class Array(Value):
# -------------------------------------------------------------------------
def __init__(self, data: dict[str, Any], name: str, label: str):
item = data.pop('items')
self.value = buildValue(item, name, label)
super().__init__(data)
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, Object]:
return self.value.objects
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
self.value.updateSpans(since, until)
self.spans = self.value.spans
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
self.value.updateRefs(objects)
# -------------------------------------------------------------------------
def defs(self) -> dict[str, Any]:
return self.value.defs()
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
out = deepcopy(self.data)
out['items'] = self.value.schema(version)
return out
# =============================================================================
class Union(Value):
# -------------------------------------------------------------------------
def __init__(self, data: list[dict[str, Any]], name: str, label: str):
self.items = []
for i, d in enumerate(data):
self.items.append(buildValue(d, name, self.itemLabel(label, i)))
super().__init__({})
# -------------------------------------------------------------------------
def itemLabel(self, label: str, index: int) -> str:
return label
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, Object]:
out = {}
for v in self.items:
out.update(v.objects)
return out
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
spans = [[(since, until)]]
for i in self.items:
i.updateSpans(since, until)
spans.append(i.spans)
self.spans = combineSpans(*spans)
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
for i in self.items:
i.updateRefs(objects)
# -------------------------------------------------------------------------
def defs(self) -> dict[str, Any]:
out = {}
for v in self.items:
out.update(v.defs())
return out
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
out = []
for i in self.items:
out.append(i.schema(version))
return {'anyOf': out}
# =============================================================================
class Variant(Union):
# -------------------------------------------------------------------------
def __init__(self, data: list[dict[str, Any]], name: str, label: str):
self.identifier = data.pop('id', None)
if self.identifier is not None:
name = self.identifier
label = self.identifier
super().__init__(data.pop('items'), name, label)
# -------------------------------------------------------------------------
def itemLabel(self, label: str, index: int) -> str:
return f'{label}[{index}]'
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, Object]:
out = super().objects
if self.identifier is not None:
out[self.identifier] = self
return out
# -------------------------------------------------------------------------
def doc(self, since: int, until: int) -> None:
return None
# -------------------------------------------------------------------------
def defs(self) -> dict[str, Any]:
out = super().defs()
if self.identifier is not None:
for s in self.spans:
n = f'{self.identifier}@{spanRef(*s)}'
out[n] = super().schema(s[0])
return out
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
if self.identifier is not None:
s = findSpan(self.spans, version)
if s is not None:
return {
'$ref': f'#/definitions/{self.identifier}@{spanRef(*s)}',
}
return {}
return super().schema(version)
# =============================================================================
class Property:
# -------------------------------------------------------------------------
def __init__(self, data: dict[str, Any], name: str, label: str):
self.name = name
self.label = label
self.since = data.pop('since', 1)
self.until = data.pop('until', LATEST + 1)
self.required = data.pop('required', False)
self.content = data.pop('sphinxDescription', data.get('description'))
note = data.pop('sphinxNote', None)
if note is not None:
self.content += f'\n\n.. note::\n\n{indent(note)}'
self._revisions = []
for rev in data.pop('revisions', []):
rd = deepcopy(data)
rd.update(rev)
self._revisions.append(Property(rd, self.name, self.label))
if len(self._revisions):
self.since = max(self.since,
min([r.since for r in self._revisions]))
self.until = min(self.until,
max([r.until for r in self._revisions]))
self.value = None
else:
self.value = buildValue(data, self.name, self.label)
self.spans = None
# -------------------------------------------------------------------------
@property
def objects(self) -> dict[str, Object]:
if len(self._revisions):
out = {}
for r in self._revisions:
out.update(r.objects)
return out
return self.value.objects
# -------------------------------------------------------------------------
def updateSpans(self, since: int, until: int) -> None:
since = max(since, self.since)
until = min(until, self.until)
if len(self._revisions):
spans = [[(since, until)]]
for r in self._revisions:
r.updateSpans(since, until)
spans.append(r.spans)
self.spans = combineSpans(*spans)
else:
self.value.updateSpans(since, until)
self.spans = self.value.spans
# -------------------------------------------------------------------------
def updateRefs(self, objects: dict[str, 'Object']) -> None:
if len(self._revisions):
for r in self._revisions:
r.updateRefs(objects)
else:
self.value.updateRefs(objects)
# -------------------------------------------------------------------------
def ref(self, version: int) -> dict[str, str] | None:
s = findSpan(self.spans, version)
if s is not None:
return {
'$ref': f'#/definitions/{self.label}@{spanRef(*s)}',
}
return None
# -------------------------------------------------------------------------
def doc(self, since: int, until: int) -> str | None:
if self.content is None:
return None
out = f'.. _`CMakePresets.{self.label}`:\n\n``{self.name}``\n'
if self.since > since:
out += f' .. presets-versionadded:: {self.since}\n\n'
if self.until < until:
out += f' .. presets-versionremoved:: {self.until}\n\n'
out += indent(self.content)
return out
# -------------------------------------------------------------------------
def defs(self) -> dict[str, Any]:
if len(self._revisions):
out = {}
for r in self._revisions:
out.update(r.defs())
return out
return self.value.defs()
# -------------------------------------------------------------------------
def schema(self, version: int) -> dict[str, Any]:
for r in self._revisions:
if version >= r.since and version < r.until:
return r.value.schema(version)
return self.value.schema(version)
# =============================================================================
class CommentProperty(Property):
name: str = '$comment'
since: int = 10
# -------------------------------------------------------------------------
def __init__(self):
data = {
'since': type(self).since,
'id': 'comment',
'type': 'variant',
'items': [
{
'type': 'string',
'description': 'A single-line comment.',
},
{
'type': 'array',
'description': 'A multi-line comment.',
'minItems': 1,
'items': {
'type': 'string',
'description': 'One line of the multi-line comment.',
}
}
],
}
super().__init__(data, type(self).name, type(self).name)
# -------------------------------------------------------------------------
def doc(self, since: int, until: int) -> None:
return None
# -----------------------------------------------------------------------------
def buildValue(
data: Any, name: str, label: str | None = None
) -> Variant | Object | Array | Ref | Value:
if type(data) is dict:
if 'anyOf' in data:
return Union(data.pop('anyOf'), name, label)
elif data['type'] == 'variant':
return Variant(data, name, label)
elif data['type'] == 'array':
return Array(data, name, label)
elif data['type'] == 'object':
return Object(data, name, label)
elif data['type'] == 'ref':
return Ref(data)
return Value(data)
# -----------------------------------------------------------------------------
def extractDivisions(spans: list[tuple[int, int]]) -> set[int]:
return {n for s in spans for n in s}
# -----------------------------------------------------------------------------
def trimSpans(spans: list[tuple[int, int]],
since: int, until: int) -> list[tuple[int, int]]:
divisions = extractDivisions(spans)
last = since
out = []
for d in sorted(divisions):
if d > since:
out.append((last, min(d, until)))
last = min(d, until)
if d >= until:
break
return out
# -----------------------------------------------------------------------------
def combineSpans(*spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
divisions = set()
for s in spans:
divisions.update(extractDivisions(s))
divisions = sorted(divisions)
last = divisions.pop(0)
out = []
for d in divisions:
out.append((last, d))
last = d
return out
# -----------------------------------------------------------------------------
def findSpan(spans: list[tuple[int, int]],
version: int) -> tuple[int, int] | None:
for s in spans:
if version >= s[0] and version < s[1]:
return s
return None
# -----------------------------------------------------------------------------
def spanRef(since: int, until: int) -> str:
if since + 1 < until:
if until > LATEST:
return f'v{since}..'
else:
return f'v{since}..v{until - 1}'
return f'v{since}'
# -----------------------------------------------------------------------------
def indent(text: str):
return '\n'.join([(' ' + line).rstrip() for line in text.split('\n')])
# -----------------------------------------------------------------------------
def trimDescriptions(data: Any):
if type(data) is dict:
for k, v in data.items():
if type(v) is str and k in {'description', 'sphinxDescription'}:
data[k] = v.strip()
elif type(v) is dict:
data[k] = trimDescriptions(v)
elif type(v) is list:
data[k] = [trimDescriptions(item) for item in v]
return data
# -----------------------------------------------------------------------------
def getPath(data: Any, path: tuple[str]) -> Any:
if len(path) == 0:
return data
return getPath(data[path[0]], path[1:])
# -----------------------------------------------------------------------------
def diagnosticPresetName(symbol: str) -> str:
sep = False
out = ''
for c in symbol[4:]:
if c == '_':
sep = True
elif sep:
out += c
sep = False
else:
out += c.lower()
return out
# -----------------------------------------------------------------------------
def buildDiagnosticsSchema(
diagnostics: list[Diagnostic],
descriptionTemplate: str,
sphinxDescriptionTemplate: str,
) -> dict[str, dict[str, Any]]:
out = {}
for d in diagnostics:
out[d.presetName] = {
'since': d.since,
'type': 'boolean',
'description': d.format(descriptionTemplate),
'sphinxDescription': d.format(sphinxDescriptionTemplate),
}
return out
# -----------------------------------------------------------------------------
def mergeDiagnostics(
generated: dict[str, dict[str, Any]],
extra: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
# Get unsorted names.
tail = {}
for k in list(extra.keys()):
s = extra[k].pop('sort', True)
if not s:
tail[k] = extra.pop(k)
# Combine inputs and sort.
out = {}
combined = deepcopy(generated)
combined.update(extra)
for k in sorted(combined.keys()):
out[k] = combined[k]
# Add unsorted items and return result.
out.update(tail)
return out
# -----------------------------------------------------------------------------
def readDiagnostics(path: Path | str) -> list[Diagnostic]:
content = ''
# Extract diagnostics table from header.
with open(path, 'r') as f:
extracting = False
for line in f:
if DIAGNOSTIC_TABLE_MACRO in line:
extracting = True
continue
if extracting:
content += line.replace('\\\n', ' ')
if not line.strip().endswith('\\'):
break
out = []
while True:
m = re.match(r'\s*SELECT[(]([^)]+)[)]', content)
if m is None:
break
args = [a.strip() for a in m.group(1).split(',')]
p = diagnosticPresetName(args[3])
c = args[3][4:].lower().replace('_', '-')
v = int(args[4])
if v > 1:
out.append(Diagnostic(p, c, v))
content = content[m.span()[1]:]
return out
# -----------------------------------------------------------------------------
def main():
# Read the schema definition.
with open(PRESETS / SCHEMA_YAML_FILENAME, 'r') as f:
schema = yaml.safe_load(f)
# Extract the current (latest) version.
global LATEST
del schema['definitions']
LATEST = schema.pop('version')
# Remove whitespace around descriptions.
schema = trimDescriptions(schema)
# Read diagnostics and update schema.
diagnostics = readDiagnostics(DIAGNOSTICS)
configureSchema = getPath(schema, CONFIGURE_PRESET_PROPERTIES_PATH)
configureSchema['warnings']['properties'] = mergeDiagnostics(
buildDiagnosticsSchema(diagnostics, WARNING_DESCRIPTION,
WARNING_SPHINX_DESCRIPTION),
configureSchema['warnings']['properties'])
configureSchema['errors']['properties'] = mergeDiagnostics(
buildDiagnosticsSchema(diagnostics, ERROR_DESCRIPTION,
ERROR_SPHINX_DESCRIPTION),
configureSchema['errors']['properties'])
# Extract global type definitions.
global TYPES, REFS
for t in schema.pop('types', []):
name = t['id']
value = buildValue(t, name)
value.updateSpans(1, LATEST + 1)
TYPES[name] = value
for t in TYPES.values():
t.updateRefs(TYPES)
# Reset the spans for references, as these currently contain only spans for
# circular references of global types, which are likely broader than the
# actual usage spans of those types. We'll recalculate the correct spans
# when we update refs from the root object.
REFS = {}
# Parse the root object. This will also recursively parse other objects.
root = Object(schema, 'root')
root.updateSpans(1, LATEST + 1)
# Resolve references.
objects = root.objects
types = objects
types.update(TYPES)
root.updateRefs(types)
for name, value in TYPES.items():
span = REFS.get(name)
if span is not None:
value.updateSpans(*span)
root.updateSpans(1, LATEST + 1)
# Extract Sphinx documentation for each object's properties.
for n, o in objects.items():
doc = o.doc(1, LATEST + 1)
if doc is not None:
print(f'- Generating reST documentation for {n} properties')
with open(PRESETS / f'{o.name}-properties.rst', 'w') as f:
print(RST_BANNER, file=f)
print(doc, file=f)
# Generate schema for each version.
versions = []
for v in range(1, LATEST + 1):
vs = root.schema(v, inline={'version'})
vs['properties']['version']['const'] = v
versions.append(vs)
# Add definitions and write the schema.
types = root.defs(exclude={'version'})
for t in TYPES.values():
types.update(t.defs())
schema = {
'$schema': 'http://json-schema.org/draft/2020-12/schema#',
'type': 'object',
'oneOf': versions,
'required': ['version'],
'definitions': types
}
with open(PRESETS / SCHEMA_JSON_FILENAME, 'w') as f:
print(json.dumps(schema, indent=2), file=f)
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
main()