[PATCH v2 1/3] dt-bindings: add DTS style checker

Daniel Golle
2026-04-29T14:21:46Z

Add a Python tool that checks DTS coding style on examples in YAML
binding files and on .dts/.dtsi/.dtso source files. Rules are kept in
a small declarative registry, each tagged 'relaxed' (default; must be
zero-violation on the current tree) or 'strict' (opt-in for new
submissions). Promoting a rule from strict to relaxed is a one-line
edit once the tree is clean.

Relaxed mode covers trailing whitespace, tab characters in YAML
examples, mixed tab+space indents, and the per-input-type indent
character (1 tab for .dts/.dtsi/.dtso). Strict mode adds:

- indent unit and depth consistency (4 spaces for YAML, 1 tab for
DTS) plus a stricter YAML variant requiring exactly 4 spaces;
- blank-line policy: no consecutive blanks or blanks at body
edges; required blank line before each child node and before
"status";
- property ordering by canonical bucket (compatible, reg/reg-names,
ranges, standard, vendor, status) with declarative within-bucket
pairing rules (<x>-names follows <x>, pinctrl-names follows the
last pinctrl-N) and natural-sort fallback;
- sibling node ordering: by unit address when present, by natural-
sort node name otherwise;
- line length capped at 80 columns;
- continuation alignment of multi-line property values under the
first '<' or '"' after the '=' sign;
- hex literals lowercase; unit addresses lowercase hex without
leading zeros; single-spaced values inside <...>; closing brace
on its own line;
- unused-label detection (skipped for .dtsi/.dtso since labels
there are exported to includers/applies-to).

Together these constrain a DT structure to a single canonical
rendering, modulo the author's choice of when to wrap a property
across lines for readability, and not covering comments.

The tool reads file paths from @argfile and parallelises across CPUs
via -j N. With no -j given it picks up $PARALLELISM (set by
scripts/jobserver-exec from the GNU make jobserver) and falls back to
os.cpu_count() otherwise. Running as one Python invocation amortises
the ruamel.yaml import across the whole tree -- ~2s on a 32-CPU host
vs ~28s sequential.

Signed-off-by: Daniel Golle <daniel@makrotopia.org>
---
Changes since v1:
- renamed dt-check-example-style -> dt-check-style; tool now also
accepts .dts/.dtsi/.dtso files directly (tab-indent variant) and
distinguishes .dts/.dtsi/.dtso so unused-labels skips .dtsi/.dtso
where labels are exported to includers/applies-to
- rules declared in a registry tagged relaxed/strict; default
relaxed mode is zero-violation on the current tree
- added -j N with $PARALLELISM (jobserver) awareness
- dropped node-name [a-z0-9-] check (Rob: better as a meta-schema)
- property-order rebuilt around buckets + declarative pairing rules
(<x>-names after <x>, pinctrl-names after last pinctrl-N) plus
natural-sort fallback
- added child-name-order, required-blank-lines, hex-case,
unit-address-format, value-whitespace, node-close-alone,
line-length and continuation-alignment to strict mode

scripts/dtc/dt-check-style | 1063 ++++++++++++++++++++++++++++++++++++
1 file changed, 1063 insertions(+)
create mode 100755 scripts/dtc/dt-check-style

diff --git a/scripts/dtc/dt-check-style b/scripts/dtc/dt-check-style
new file mode 100755
index 000000000000..477d3e427fc1
--- /dev/null
+++ b/scripts/dtc/dt-check-style
@@ -0,0 +1,1063 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Check DTS coding style on YAML binding examples and on
+# .dts/.dtsi/.dtso source files. Enforces rules from
+# Documentation/devicetree/bindings/dts-coding-style.rst.
+#
+# Two modes:
+# --mode=relaxed (default)
+# Only rules that produce zero warnings on the current tree.
+# Suitable for dt_binding_check.
+# --mode=strict
+# All rules. Required for new submissions.
+#
+# Two input types (auto-detected by file extension):
+# *.yaml -- DT binding; check each example block
+# *.dts/*.dtsi/*.dtso -- DTS source; whole file is one block
+#
+# Rules are declared in a registry (see RULES below); each rule is
+# tagged with the lowest mode that runs it. Promoting a rule from
+# 'strict' to 'relaxed' is a one-line change.
+
+import argparse
+import re
+import sys
+from enum import Enum, auto
+
+import ruamel.yaml
+
+
+# ---------------------------------------------------------------------------
+# Line classification
+# ---------------------------------------------------------------------------
+
+class LineType(Enum):
+ BLANK = auto()
+ COMMENT = auto() # // ... or /* ... */ on one line
+ COMMENT_START = auto() # /* without closing */
+ COMMENT_BODY = auto() # inside a multi-line comment
+ COMMENT_END = auto() # closing */
+ PREPROCESSOR = auto() # #include / #define / #ifdef / ...
+ NODE_OPEN = auto() # something { (with optional label/name/addr)
+ NODE_CLOSE = auto() # };
+ PROPERTY = auto() # name = value; or name;
+ CONTINUATION = auto() # continuation of a multi-line property
+
+
+re_cpp_directive = re.compile(
+ r'^#\s*(include|define|undef|ifdef|ifndef|if|else|elif|endif|'
+ r'pragma|error|warning)\b')
+
+# label: name@addr { -- label and addr optional; name can be "/"
+re_node_header = re.compile(
+ r'^(?:([a-zA-Z_][a-zA-Z0-9_]*):\s*)?'
+ r'([a-zA-Z][a-zA-Z0-9,._+-]*|/)'
+ r'(?:@([0-9a-fA-F,]+))?'
+ r'\s*\{$')
+
+re_ref_node = re.compile(
+ r'^&([a-zA-Z_][a-zA-Z0-9_]*)\s*\{$')
+
+
+def is_preprocessor(stripped):
+ """Tell C preprocessor directives apart from DTS '#'-prefixed props."""
+ return re_cpp_directive.match(stripped) is not None
+
+
+class DtsLine:
+ __slots__ = ('lineno', 'raw', 'linetype', 'indent_str', 'stripped',
+ 'prop_name', 'continuations',
+ 'node_name', 'node_addr', 'label', 'ref_name', 'depth')
+
+ def __init__(self, lineno, raw, linetype, indent_str, stripped):
+ self.lineno = lineno # 1-based within the block
+ self.raw = raw
+ self.linetype = linetype
+ self.indent_str = indent_str # leading whitespace as-is
+ self.stripped = stripped
+ self.prop_name = None
+ self.continuations = []
+ self.node_name = None
+ self.node_addr = None
+ self.label = None
+ self.ref_name = None
+ self.depth = 0 # filled in by classify_lines
+
+
+def classify_lines(text):
+ """Return a list of DtsLine. Tracks { } depth and groups
+ continuation lines onto their leading PROPERTY line."""
+ out = []
+ in_block_comment = False
+ prev_complete = True
+ depth = 0
+
+ # Split preserving the indent string verbatim
+ re_lead = re.compile(r'^([ \t]*)(.*)$')
+
+ for i, raw in enumerate(text.split('\n'), start=1):
+ m = re_lead.match(raw)
+ indent_str = m.group(1)
+ stripped = m.group(2)
+
+ if not stripped:
+ dl = DtsLine(i, raw, LineType.BLANK, '', '')
+ dl.depth = depth
+ out.append(dl)
+ continue
+
+ if in_block_comment:
+ ltype = (LineType.COMMENT_END if '*/' in stripped
+ else LineType.COMMENT_BODY)
+ if ltype == LineType.COMMENT_END:
+ in_block_comment = False
+ dl = DtsLine(i, raw, ltype, indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ continue
+
+ if stripped.startswith('/*'):
+ if '*/' in stripped:
+ ltype = LineType.COMMENT
+ else:
+ in_block_comment = True
+ ltype = LineType.COMMENT_START
+ dl = DtsLine(i, raw, ltype, indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ continue
+
+ if stripped.startswith('//'):
+ dl = DtsLine(i, raw, LineType.COMMENT, indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ continue
+
+ if stripped.startswith('#') and is_preprocessor(stripped):
+ dl = DtsLine(i, raw, LineType.PREPROCESSOR,
+ indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ prev_complete = True
+ continue
+
+ if not prev_complete:
+ dl = DtsLine(i, raw, LineType.CONTINUATION,
+ indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ prev_complete = (stripped.endswith(';') or
+ stripped.endswith('{') or
+ stripped.endswith('};'))
+ continue
+
+ if stripped == '};' or stripped == '}':
+ depth = max(depth - 1, 0)
+ dl = DtsLine(i, raw, LineType.NODE_CLOSE, indent_str, stripped)
+ dl.depth = depth
+ out.append(dl)
+ prev_complete = True
+ continue
+
+ if stripped.endswith('{'):
+ dl = DtsLine(i, raw, LineType.NODE_OPEN, indent_str, stripped)
+ parse_node_header(dl)
+ dl.depth = depth
+ out.append(dl)
+ depth += 1
+ prev_complete = True
+ continue
+
+ # Property (or first line of a multi-line property).
+ dl = DtsLine(i, raw, LineType.PROPERTY, indent_str, stripped)
+ parse_property_name(dl)
+ dl.depth = depth
+ out.append(dl)
+ prev_complete = stripped.endswith(';')
+
+ # Group continuation lines onto their leading PROPERTY.
+ last_prop = None
+ grouped = []
+ for dl in out:
+ if dl.linetype == LineType.CONTINUATION and last_prop is not None:
+ last_prop.continuations.append(dl)
+ continue
+ if dl.linetype == LineType.PROPERTY:
+ last_prop = dl
+ elif dl.linetype != LineType.BLANK and \
+ dl.linetype not in (LineType.COMMENT, LineType.COMMENT_BODY,
+ LineType.COMMENT_END,
+ LineType.COMMENT_START):
+ last_prop = None
+ grouped.append(dl)
+ return grouped
+
+
+def parse_node_header(dl):
+ m = re_node_header.match(dl.stripped)
+ if m:
+ dl.label = m.group(1)
+ dl.node_name = m.group(2)
+ dl.node_addr = m.group(3)
+ return
+ m = re_ref_node.match(dl.stripped)
+ if m:
+ dl.ref_name = m.group(1)
+
+
+def parse_property_name(dl):
+ m = re.match(r'^([a-zA-Z#][a-zA-Z0-9,._+#-]*)\s*[=;]', dl.stripped)
+ if m:
+ dl.prop_name = m.group(1)
+
+
+def collect_labels_and_refs(text):
+ """Return (defined_labels, referenced_labels) found anywhere outside
+ /* */ comments. Labels named fake_intc* (injected by
+ dt-extract-example) are skipped."""
+ # Strip block comments first so labels inside them don't count
+ stripped = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL)
+ # Strip line comments
+ stripped = re.sub(r'//[^\n]*', '', stripped)
+ defined = set()
+ referenced = set()
+ for m in re.finditer(r'(?:^|[\s{])([a-zA-Z_][a-zA-Z0-9_]*):\s*[a-zA-Z/&]',
+ stripped):
+ name = m.group(1)
+ if not name.startswith('fake_intc'):
+ defined.add(name)
+ for m in re.finditer(r'&([a-zA-Z_][a-zA-Z0-9_]*)', stripped):
+ referenced.add(m.group(1))
+ return defined, referenced
+
+
+# ---------------------------------------------------------------------------
+# Rule registry
+# ---------------------------------------------------------------------------
+
+class Ctx:
+ """Context passed to each rule check. Carries the parsed lines,
+ raw text, mode, and indent kind."""
+
+ def __init__(self, lines, text, mode, indent_kind):
+ self.lines = lines
+ self.text = text
+ self.mode = mode # 'relaxed' or 'strict'
+ self.indent_kind = indent_kind # 'spaces' or 'tab'
+
+
+class Rule:
+ __slots__ = ('name', 'mode', 'description', 'check', 'applies_to')
+
+ def __init__(self, name, mode, description, check,
+ applies_to=('yaml', 'dts', 'dtsi', 'dtso')):
+ self.name = name
+ self.mode = mode # 'relaxed' or 'strict'
+ self.description = description
+ self.check = check
+ self.applies_to = applies_to # input types this rule covers
+
+
+# --- individual rule check functions --------------------------------------
+
+def check_trailing_whitespace(ctx):
+ for dl in ctx.lines:
+ if dl.raw != dl.raw.rstrip():
+ yield (dl.lineno, 'trailing whitespace')
+
+
+def check_tab_in_dts(ctx):
+ """Reject literal tabs in DTS lines when input is YAML.
+
+ For YAML examples, indent and content must use spaces. Tabs inside
+ a #define value are tolerated (those are CPP macros, not DTS).
+ For .dts files, this rule does not apply -- tabs are required.
+ """
+ if ctx.indent_kind != 'spaces':
+ return
+ for dl in ctx.lines:
+ if dl.linetype == LineType.PREPROCESSOR:
+ continue
+ if dl.linetype == LineType.BLANK:
+ continue
+ if '\t' in dl.raw:
+ yield (dl.lineno, 'tab character not allowed in DTS example')
+
+
+def check_mixed_indent_chars(ctx):
+ """Indent must be all-spaces or all-tabs, never mixed on one line."""
+ for dl in ctx.lines:
+ if not dl.indent_str:
+ continue
+ if dl.linetype == LineType.PREPROCESSOR:
+ continue
+ if ' ' in dl.indent_str and '\t' in dl.indent_str:
+ yield (dl.lineno, 'mixed tabs and spaces in indent')
+
+
+def detect_indent_unit(ctx):
+ """Find the indent unit used at depth 1 in this block.
+
+ Returns one of: ' ' (2 spaces), ' ' (4 spaces), '\\t' (tab),
+ or None if depth-1 is empty or ambiguous."""
+ for dl in ctx.lines:
+ if dl.depth != 1:
+ continue
+ if dl.linetype in (LineType.BLANK, LineType.PREPROCESSOR):
+ continue
+ if dl.linetype in (LineType.COMMENT_BODY, LineType.COMMENT_END):
+ continue
+ if not dl.indent_str:
+ continue
+ if dl.indent_str == '\t':
+ return '\t'
+ if dl.indent_str == ' ':
+ return ' '
+ if dl.indent_str == ' ':
+ return ' '
+ # Anything else at depth 1 is non-canonical; flag elsewhere.
+ return dl.indent_str
+ return None
+
+
+def check_indent_unit_relaxed(ctx):
+ """YAML examples: 2 or 4 spaces. Never tabs or other widths."""
+ unit = detect_indent_unit(ctx)
+ if unit is None:
+ return
+ if unit not in (' ', ' '):
+ yield (1, 'indent unit must be 2 or 4 spaces, got %r' % unit)
+
+
+def check_indent_unit_dts(ctx):
+ """DTS files: 1 tab per level. Always required."""
+ unit = detect_indent_unit(ctx)
+ if unit is None:
+ return
+ if unit != '\t':
+ yield (1, 'indent unit must be 1 tab in DTS, got %r' % unit)
+
+
+def check_indent_unit_strict(ctx):
+ """YAML: must be exactly 4 spaces. DTS: 1 tab (same as relaxed)."""
+ unit = detect_indent_unit(ctx)
+ if unit is None:
+ return
+ if ctx.indent_kind == 'spaces':
+ if unit != ' ':
+ yield (1, 'indent unit must be 4 spaces in strict mode, '
+ 'got %r' % unit)
+
+
+def check_indent_consistent(ctx):
+ """All indented lines must be a multiple of the detected unit."""
+ unit = detect_indent_unit(ctx)
+ if unit is None:
+ return
+ if ctx.indent_kind == 'spaces':
+ if unit not in (' ', ' '):
+ return # let check_indent_unit_* report this
+ else:
+ if unit != '\t':
+ return
+
+ for dl in ctx.lines:
+ if dl.linetype in (LineType.BLANK, LineType.PREPROCESSOR):
+ continue
+ if dl.linetype == LineType.CONTINUATION:
+ continue # continuations align to <, not to indent unit
+ if dl.linetype in (LineType.COMMENT_BODY, LineType.COMMENT_END):
+ continue
+ if not dl.indent_str:
+ continue
+ # The indent must be 'unit' repeated dl.depth times, exactly.
+ # NODE_CLOSE lines have depth equal to the post-decrement value,
+ # which matches the indent expected.
+ expected = unit * dl.depth
+ if dl.indent_str != expected:
+ yield (dl.lineno,
+ 'indent mismatch (expected depth %d * %r)' %
+ (dl.depth, unit))
+
+
+def check_blank_lines(ctx):
+ """No two consecutive blank lines, no leading/trailing blank lines
+ in any node body."""
+ lines = ctx.lines
+ # Consecutive blanks
+ for i in range(1, len(lines)):
+ if lines[i].linetype == LineType.BLANK and \
+ lines[i - 1].linetype == LineType.BLANK:
+ yield (lines[i].lineno, 'consecutive blank lines')
+ # Blank right after { or right before }
+ for i, dl in enumerate(lines):
+ if dl.linetype != LineType.BLANK:
+ continue
+ prev = lines[i - 1] if i > 0 else None
+ nxt = lines[i + 1] if i + 1 < len(lines) else None
+ if prev is not None and prev.linetype == LineType.NODE_OPEN:
+ yield (dl.lineno, 'blank line at start of node body')
+ if nxt is not None and nxt.linetype == LineType.NODE_CLOSE:
+ yield (dl.lineno, 'blank line at end of node body')
+
+
+def _walk_bodies(lines):
+ """Yield lists of immediate-child NODE_OPEN lines for each node body
+ in the input. Skips ref-nodes (&label) since those don't have an
+ intrinsic ordering."""
+ body_stack = [[]]
+ for dl in lines:
+ if dl.linetype == LineType.NODE_OPEN:
+ body_stack[-1].append(dl)
+ body_stack.append([])
+ continue
+ if dl.linetype == LineType.NODE_CLOSE:
+ if len(body_stack) <= 1:
+ # Unbalanced; ignore to avoid crashing on malformed input
+ continue
+ yield body_stack.pop()
+ continue
+ while body_stack:
+ yield body_stack.pop()
+
+
+def _natural_sort_key(s):
+ """Split a string into a tuple of (kind, value) pairs that compares
+ numeric runs as ints, so 'foo10' sorts after 'foo2'."""
+ parts = []
+ for part in re.split(r'(\d+)', s):
+ if part.isdigit():
+ parts.append((0, int(part)))
+ else:
+ parts.append((1, part))
+ return tuple(parts)
+
+
+def check_child_address_order(ctx):
+ """Addressed siblings (foo@N) must appear in ascending address
+ order within their parent node body."""
+ for children in _walk_bodies(ctx.lines):
+ addressed = []
+ for c in children:
+ if c.node_addr is None:
+ continue
+ try:
+ parts = tuple(int(p, 16) for p in c.node_addr.split(','))
+ except ValueError:
+ continue
+ addressed.append((parts, c))
+ for i in range(1, len(addressed)):
+ if addressed[i][0] < addressed[i - 1][0]:
+ dl = addressed[i][1]
+ yield (dl.lineno,
+ 'child node @%s out of address order' %
+ dl.node_addr)
+
+
+def check_child_name_order(ctx):
+ """Unaddressed siblings must appear in natural-sort order by node
+ name within their parent node body. Addressed children are scoped
+ by check_child_address_order; reference nodes (&label { ... }) and
+ the root node are skipped."""
+ for children in _walk_bodies(ctx.lines):
+ unaddressed = []
+ for c in children:
+ if c.node_addr is not None:
+ continue
+ if c.node_name in (None, '/'):
+ continue
+ if c.ref_name is not None:
+ continue
+ unaddressed.append((_natural_sort_key(c.node_name), c))
+ for i in range(1, len(unaddressed)):
+ if unaddressed[i][0] < unaddressed[i - 1][0]:
+ dl = unaddressed[i][1]
+ yield (dl.lineno,
+ 'child node %r out of name order' % dl.node_name)
+
+
+def _property_bucket(name):
+ """Return the canonical bucket index for a property:
+ 0 compatible
+ 1 reg / reg-names
+ 2 ranges
+ 3 standard properties (no vendor comma in #-stripped name)
+ 4 vendor-specific properties
+ 5 status
+ Plus a sub-key inside the bucket for fixed slots (compatible, reg,
+ reg-names, ranges, status). 'standard' and 'vendor' return None for
+ the sub-key, signalling that the within-bucket key is computed by
+ the pairing rules."""
+ stripped = name.lstrip('#')
+ if name == 'compatible':
+ return (0, 0)
+ if name == 'reg':
+ return (1, 0)
+ if name == 'reg-names':
+ return (1, 1)
+ if name == 'ranges':
+ return (2, 0)
+ if name == 'status':
+ return (5, 0)
+ return (4 if ',' in stripped else 3, None)
+
+
+# Declarative pairing rules: each is a callable
+# (name, all_names) -> anchor_name_or_None
+# If a rule returns an anchor, the property sorts immediately after the
+# anchor. Rules are tried in order; the first match wins. If none
+# matches, the within-bucket key falls back to natural sort by the
+# #-stripped name.
+
+def _pair_pinctrl_names(name, all_names):
+ """pinctrl-names follows the highest pinctrl-N in the same node."""
+ if name != 'pinctrl-names':
+ return None
+ cands = [n for n in all_names if re.match(r'^pinctrl-\d+$', n)]
+ if not cands:
+ return None
+ return max(cands, key=_natural_sort_key)
+
+
+def _pair_x_names(name, all_names):
+ """Generic <x>-names follows its owning property. The owner is
+ usually plural (clocks/clock-names, dmas/dma-names,
+ resets/reset-names) but occasionally singular (reg/reg-names is
+ handled by the fixed slot above; this rule catches anything else)."""
+ if not name.endswith('-names'):
+ return None
+ base = name[:-len('-names')]
+ # Try plural and singular forms.
+ if (base + 's') in all_names:
+ return base + 's'
+ if base in all_names:
+ return base
+ return None
+
+
+PAIRING_RULES = (_pair_pinctrl_names, _pair_x_names)
+
+
+def _property_sort_key(name, all_names):
+ """Sort key for a property among its node-body siblings.
+
+ Format: (bucket, within_key, tiebreak). 'within_key' for
+ standard/vendor buckets follows pairing rules: a property paired
+ with anchor X sorts as if it were X with a higher tiebreak."""
+ bucket, fixed_sub = _property_bucket(name)
+ if fixed_sub is not None:
+ return (bucket, (), fixed_sub)
+
+ for rule in PAIRING_RULES:
+ anchor = rule(name, all_names)
+ if anchor is not None:
+ return (bucket, _natural_sort_key(anchor.lstrip('#')), 1)
+
+ return (bucket, _natural_sort_key(name.lstrip('#')), 0)
+
+
+def check_property_order(ctx):
+ """Properties within a node body must appear in canonical order:
+ compatible, reg(/reg-names), ranges, then the standard group, then
+ the vendor-specific group, then status. Inside the standard and
+ vendor groups, pairing rules apply (e.g. <x>-names follows <x>);
+ everything else falls back to natural sort by the #-stripped name."""
+ lines = ctx.lines
+ for i, dl in enumerate(lines):
+ if dl.linetype != LineType.NODE_OPEN:
+ continue
+ body_depth = dl.depth + 1
+ props = []
+ for j in range(i + 1, len(lines)):
+ d = lines[j]
+ if d.linetype == LineType.NODE_CLOSE and \
+ d.depth == body_depth - 1:
+ break
+ if d.linetype == LineType.PROPERTY and d.depth == body_depth \
+ and d.prop_name is not None:
+ props.append(d)
+ if len(props) < 2:
+ continue
+ all_names = [p.prop_name for p in props]
+ keyed = [(p, _property_sort_key(p.prop_name, all_names))
+ for p in props]
+ for k in range(1, len(keyed)):
+ if keyed[k][1] < keyed[k - 1][1]:
+ p = keyed[k][0]
+ prev = keyed[k - 1][0]
+ yield (p.lineno,
+ 'property %r out of canonical order '
+ '(should sort before %r)' %
+ (p.prop_name, prev.prop_name))
+
+
+def _strip_strings_and_comments(text):
+ """Remove string literals and /* */ + // comments from a single
+ line, replacing them with empty strings. Used so syntactic checks
+ (whitespace, hex case, etc.) don't false-positive on contents of
+ quoted strings or comments."""
+ text = re.sub(r'"(?:[^"\\]|\\.)*"', '""', text)
+ text = re.sub(r'/\*.*?\*/', '', text)
+ text = re.sub(r'//.*$', '', text)
+ return text
+
+
+def check_required_blank_lines(ctx):
+ """A blank line must precede each child node and the 'status'
+ property within a node body, except when these are the first
+ substantive item in the body."""
+ lines = ctx.lines
+ for i, open_dl in enumerate(lines):
+ if open_dl.linetype != LineType.NODE_OPEN:
+ continue
+ body_depth = open_dl.depth + 1
+ prev_substantive = None
+ between_blanks = 0
+ depth_inside = 0
+ for j in range(i + 1, len(lines)):
+ d = lines[j]
+ if d.linetype == LineType.NODE_CLOSE and \
+ d.depth == body_depth - 1 and depth_inside == 0:
+ break
+ # Track depth inside nested children so we only look at
+ # immediate-body items.
+ if d.linetype == LineType.NODE_OPEN and \
+ d.depth >= body_depth and depth_inside > 0:
+ depth_inside += 1
+ continue
+ if d.linetype == LineType.NODE_CLOSE and depth_inside > 0:
+ depth_inside -= 1
+ continue
+ if depth_inside > 0:
+ continue
+ if d.linetype == LineType.BLANK:
+ if prev_substantive is not None:
+ between_blanks += 1
+ continue
+ if d.linetype in (LineType.COMMENT, LineType.COMMENT_START,
+ LineType.COMMENT_BODY, LineType.COMMENT_END,
+ LineType.PREPROCESSOR):
+ continue
+ if d.linetype == LineType.CONTINUATION:
+ continue
+
+ needs_blank = False
+ if d.linetype == LineType.NODE_OPEN:
+ needs_blank = True
+ depth_inside = 1 # entered the child body
+ elif d.linetype == LineType.PROPERTY and d.prop_name == 'status':
+ needs_blank = True
+
+ if needs_blank and prev_substantive is not None and \
+ between_blanks == 0:
+ if d.linetype == LineType.NODE_OPEN:
+ yield (d.lineno,
+ 'child node must be preceded by a blank line')
+ else:
+ yield (d.lineno,
+ '"status" must be preceded by a blank line')
+
+ prev_substantive = d
+ between_blanks = 0
+
+
+def check_hex_case(ctx):
+ """Hex literals (0xN) must use lowercase digits and prefix."""
+ for dl in ctx.lines:
+ if dl.linetype in (LineType.BLANK, LineType.COMMENT,
+ LineType.COMMENT_START, LineType.COMMENT_BODY,
+ LineType.COMMENT_END, LineType.PREPROCESSOR):
+ continue
+ text = _strip_strings_and_comments(dl.raw)
+ for m in re.finditer(r'\b0[xX][0-9a-fA-F]+\b', text):
+ lit = m.group(0)
+ if any(c.isupper() for c in lit[2:]) or lit[1] == 'X':
+ yield (dl.lineno,
+ 'hex literal %r must be lowercase' % lit)
+
+
+def check_unit_address_format(ctx):
+ """Unit addresses must be lowercase hex without leading zeros.
+ For multi-cell addresses (comma-separated), each part is checked
+ independently. A single '0' is permitted (canonical zero)."""
+ for dl in ctx.lines:
+ if dl.linetype != LineType.NODE_OPEN:
+ continue
+ if dl.node_addr is None:
+ continue
+ addr = dl.node_addr
+ for part in addr.split(','):
+ if any(c in 'ABCDEF' for c in part):
+ yield (dl.lineno,
+ 'unit address %r must be lowercase hex' % addr)
+ break
+ if len(part) > 1 and part.startswith('0'):
+ yield (dl.lineno,
+ 'unit address %r has leading zeros' % addr)
+ break
+
+
+def check_value_whitespace(ctx):
+ """Inside a <...> cell list use single spaces between values; no
+ leading or trailing whitespace inside the brackets. Outside
+ strings and comments only."""
+ for dl in ctx.lines:
+ if dl.linetype not in (LineType.PROPERTY, LineType.CONTINUATION):
+ continue
+ text = _strip_strings_and_comments(dl.raw)
+ for m in re.finditer(r'<([^<>]*)>', text):
+ content = m.group(1)
+ if content != content.strip():
+ yield (dl.lineno,
+ 'extra whitespace inside <...>')
+ break
+ if re.search(r'[ \t]{2,}', content):
+ yield (dl.lineno,
+ 'multiple spaces inside <...>')
+ break
+
+
+def check_node_close_alone(ctx):
+ """The closing '};' of a node must be on its own line. The
+ classifier already accepts only `};` or `}` as NODE_CLOSE; any
+ other line that still contains `};` (in code, not in strings or
+ comments) is mixing a node close with something else."""
+ for dl in ctx.lines:
+ if dl.linetype in (LineType.BLANK, LineType.COMMENT,
+ LineType.COMMENT_START, LineType.COMMENT_BODY,
+ LineType.COMMENT_END, LineType.PREPROCESSOR,
+ LineType.NODE_CLOSE):
+ continue
+ text = _strip_strings_and_comments(dl.raw)
+ if '};' in text:
+ yield (dl.lineno,
+ 'closing brace must be on its own line')
+
+
+def check_line_length(ctx):
+ """Lines must not exceed 80 characters. Trailing newlines do not
+ count. Tabs in DTS files are counted as 8 columns to match how
+ `printf` and most editors render them, matching the kernel-wide
+ line length convention."""
+ for dl in ctx.lines:
+ if dl.linetype == LineType.BLANK:
+ continue
+ # Compute display width: tabs count as 8 columns, advancing to
+ # the next multiple of 8.
+ cols = 0
+ for ch in dl.raw:
+ if ch == '\t':
+ cols = (cols // 8 + 1) * 8
+ else:
+ cols += 1
+ if cols > 80:
+ yield (dl.lineno,
+ 'line exceeds 80 columns (%d)' % cols)
+
+
+def check_continuation_alignment(ctx):
+ """A multi-line property's continuation lines must align their
+ first non-whitespace character to the column of the first '<' or
+ '"' after the '=' in the leading line."""
+ for dl in ctx.lines:
+ if dl.linetype != LineType.PROPERTY:
+ continue
+ if not dl.continuations:
+ continue
+ eq = dl.raw.find('=')
+ if eq < 0:
+ continue
+ # First '<' or '"' after '='
+ rest = dl.raw[eq + 1:]
+ m = re.search(r'[<"]', rest)
+ if not m:
+ continue
+ target_col = eq + 1 + m.start()
+ for cont in dl.continuations:
+ if len(cont.indent_str) != target_col:
+ yield (cont.lineno,
+ 'continuation should align to column %d '
+ '(under "<" or \\")' % (target_col + 1))
+
+
+def check_unused_labels(ctx):
+ """Labels defined but never referenced are clutter."""
+ defined, referenced = collect_labels_and_refs(ctx.text)
+ for label in sorted(defined - referenced):
+ # Find the line where this label is defined for line-number
+ # reporting.
+ m = re.search(r'(?m)^.*\b' + re.escape(label) + r'\s*:', ctx.text)
+ lineno = ctx.text[:m.start()].count('\n') + 1 if m else 1
+ yield (lineno, 'label %r defined but never &-referenced' % label)
+
+
+# --- registry --------------------------------------------------------------
+
+RULES = [
+ # 'relaxed' is the default; rules in this group must produce zero
+ # output on a clean kernel tree (post the small prep-cleanup
+ # commit at the head of this series).
+ Rule('trailing-whitespace', 'relaxed',
+ 'no trailing whitespace on any line',
+ check_trailing_whitespace),
+ Rule('tab-in-dts', 'relaxed',
+ 'YAML examples may not contain tab characters',
+ check_tab_in_dts, applies_to=('yaml',)),
+ Rule('mixed-indent-chars', 'relaxed',
+ 'indent must not mix tabs and spaces',
+ check_mixed_indent_chars),
+
+ # DTS files always use tabs; this is not negotiable per kernel
+ # coding style (.dts files are real source). Relaxed mode.
+ Rule('indent-unit-dts', 'relaxed',
+ 'DTS files: 1 tab per nesting level',
+ check_indent_unit_dts,
+ applies_to=('dts', 'dtsi', 'dtso')),
+
+ # 'strict' rules are opt-in (e.g. for new submissions via
+ # checkpatch.pl in a follow-up series). They flag many existing
+ # files and can be promoted to relaxed once those are cleaned up.
+ Rule('indent-unit', 'strict',
+ 'YAML: 2 or 4 spaces per level',
+ check_indent_unit_relaxed, applies_to=('yaml',)),
+ Rule('indent-unit-strict', 'strict',
+ 'YAML: must be 4 spaces per level',
+ check_indent_unit_strict, applies_to=('yaml',)),
+ Rule('indent-consistent', 'strict',
+ 'every line indented at depth * unit',
+ check_indent_consistent),
+ Rule('blank-lines', 'strict',
+ 'no consecutive blanks; no blanks at node body edges',
+ check_blank_lines),
+ Rule('child-address-order', 'strict',
+ 'addressed siblings must be in ascending address order',
+ check_child_address_order),
+ Rule('child-name-order', 'strict',
+ 'unaddressed siblings must be in natural-sort name order',
+ check_child_name_order),
+ Rule('property-order', 'strict',
+ 'canonical bucket + pairing + natural-sort order of properties',
+ check_property_order),
+ Rule('required-blank-lines', 'strict',
+ 'blank line before child nodes and before "status"',
+ check_required_blank_lines),
+ Rule('hex-case', 'strict',
+ 'hex literals must be lowercase',
+ check_hex_case),
+ Rule('unit-address-format', 'strict',
+ 'unit addresses must be lowercase hex without leading zeros',
+ check_unit_address_format),
+ Rule('value-whitespace', 'strict',
+ 'single-spaced values inside <...>',
+ check_value_whitespace),
+ Rule('node-close-alone', 'strict',
+ 'closing brace must be on its own line',
+ check_node_close_alone),
+ Rule('line-length', 'strict',
+ 'lines must not exceed 80 columns',
+ check_line_length),
+ Rule('continuation-alignment', 'strict',
+ 'multi-line property continuations align under "<" or "\\""',
+ check_continuation_alignment),
+ Rule('unused-labels', 'strict',
+ 'every label must be &-referenced in the same example/file '
+ '(skipped for .dtsi/.dtso since labels there are exported)',
+ check_unused_labels, applies_to=('yaml', 'dts')),
+]
+
+
+def select_rules(mode, input_kind):
+ """Return rules that apply to the given mode and input type."""
+ rank = {'relaxed': 0, 'strict': 1}
+ out = []
+ for r in RULES:
+ if rank[r.mode] > rank[mode]:
+ continue
+ if input_kind not in r.applies_to:
+ continue
+ out.append(r)
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Block runner
+# ---------------------------------------------------------------------------
+
+def check_block(text, mode, indent_kind, input_type):
+ """Run all selected rules on a single block of DTS text. Returns a
+ list of (lineno, rule_name, message) tuples."""
+ lines = classify_lines(text)
+ ctx = Ctx(lines, text, mode, indent_kind)
+ rules = select_rules(mode, input_type)
+ findings = []
+ for r in rules:
+ for lineno, msg in r.check(ctx):
+ findings.append((lineno, r.name, msg))
+ findings.sort(key=lambda t: (t[0], t[1]))
+ return findings
+
+
+# ---------------------------------------------------------------------------
+# Input drivers (YAML examples vs raw DTS)
+# ---------------------------------------------------------------------------
+
+def _yaml_loader():
+ return ruamel.yaml.YAML()
+
+
+def iter_yaml_examples(filepath):
+ """Yield (example_text, base_lineno_in_file, example_index) tuples."""
+ yaml = _yaml_loader()
+ try:
+ with open(filepath, encoding='utf-8') as f:
+ data = yaml.load(f)
+ except Exception as e:
+ print('%s: error loading YAML: %s' % (filepath, e),
+ file=sys.stderr)
+ return
+ if not isinstance(data, dict) or 'examples' not in data:
+ return
+ examples = data['examples']
+ if not hasattr(examples, '__iter__'):
+ return
+ for i, ex in enumerate(examples):
+ if not isinstance(ex, str):
+ continue
+ try:
+ base = examples.lc.item(i)[0] + 2
+ except Exception:
+ base = 1
+ yield (str(ex), base, i)
+
+
+def iter_dts_file(filepath):
+ """Treat the whole file as a single block."""
+ try:
+ with open(filepath, encoding='utf-8') as f:
+ text = f.read()
+ except Exception as e:
+ print('%s: error reading: %s' % (filepath, e), file=sys.stderr)
+ return
+ yield (text, 1, None)
+
+
+# ---------------------------------------------------------------------------
+# Top-level processing
+# ---------------------------------------------------------------------------
+
+def input_kind(filepath):
+ p = filepath.lower()
+ if p.endswith('.yaml') or p.endswith('.yml'):
+ return 'yaml'
+ if p.endswith('.dts'):
+ return 'dts'
+ if p.endswith('.dtsi'):
+ return 'dtsi'
+ if p.endswith('.dtso'):
+ return 'dtso'
+ return None
+
+
+# All input types that use tab indentation and follow DTS coding style.
+DTS_FAMILY = ('dts', 'dtsi', 'dtso')
+
+
+def collect_findings(filepath, mode):
+ """Return a (lines, count) pair for filepath. lines is a list of
+ formatted output strings; count is the number of findings."""
+ kind = input_kind(filepath)
+ if kind == 'yaml':
+ indent_kind = 'spaces'
+ iterator = iter_yaml_examples(filepath)
+ elif kind in DTS_FAMILY:
+ indent_kind = 'tab'
+ iterator = iter_dts_file(filepath)
+ else:
+ return (['%s: unknown file type, skipping' % filepath], 0)
+
+ out = []
+ for text, base, idx in iterator:
+ for lineno, rule, msg in check_block(text, mode, indent_kind, kind):
+ abs_line = base + lineno - 1
+ ex_tag = '' if idx is None else ' example %d' % idx
+ out.append('%s:%d:%s [%s] %s' %
+ (filepath, abs_line, ex_tag, rule, msg))
+ return (out, len(out))
+
+
+# Worker entry point for ProcessPoolExecutor.map(). Top-level so it is
+# picklable on every platform.
+def _worker(args):
+ filepath, mode = args
+ return collect_findings(filepath, mode)
+
+
+def main():
+ import os
+ ap = argparse.ArgumentParser(
+ description='Check DTS coding style on YAML examples and '
+ '.dts/.dtsi/.dtso files.',
+ fromfile_prefix_chars='@')
+ ap.add_argument('--mode', choices=('relaxed', 'strict'),
+ default='relaxed',
+ help='which rule set to apply (default: relaxed)')
+ ap.add_argument('-j', '--jobs', type=int, default=0,
+ metavar='N',
+ help='run N workers in parallel (default: respect '
+ 'the make jobserver via $PARALLELISM, otherwise '
+ 'os.cpu_count(); use 1 to disable multiprocessing)')
+ ap.add_argument('--list-rules', action='store_true',
+ help='print all rules with their mode and exit')
+ ap.add_argument('files', nargs='*', metavar='file',
+ help='YAML binding files or .dts/.dtsi/.dtso files; '
+ 'use @argfile to read paths from a file')
+ args = ap.parse_args()
+
+ if args.list_rules:
+ for r in RULES:
+ applies = ','.join(r.applies_to)
+ print('%-22s %-7s [%s] %s' %
+ (r.name, r.mode, applies, r.description))
+ return 0
+
+ if not args.files:
+ ap.error('no input files')
+
+ if args.jobs > 0:
+ jobs = args.jobs
+ else:
+ # When invoked under scripts/jobserver-exec, $PARALLELISM
+ # holds the slot count make has reserved for us; this lets
+ # `make -j N dt_binding_check` constrain our worker pool to N.
+ try:
+ jobs = int(os.environ['PARALLELISM'])
+ except (KeyError, ValueError):
+ jobs = os.cpu_count() or 1
+ # Single-process path: keep import surface small for tests and
+ # easy debugging.
+ if jobs == 1 or len(args.files) == 1:
+ total = 0
+ for f in args.files:
+ lines, n = collect_findings(f, args.mode)
+ for line in lines:
+ print(line)
+ total += n
+ return 1 if total else 0
+
+ # Multi-process path. ex.map preserves input order so output is
+ # deterministic across runs.
+ from concurrent.futures import ProcessPoolExecutor
+ total = 0
+ work = [(f, args.mode) for f in args.files]
+ chunk = max(1, len(work) // (jobs * 8)) if work else 1
+ with ProcessPoolExecutor(max_workers=jobs) as ex:
+ for lines, n in ex.map(_worker, work, chunksize=chunk):
+ for line in lines:
+ print(line)
+ total += n
+ return 1 if total else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
--
2.54.0