From a3f7254b6385c56de446992df6823f47f4276e6f Mon Sep 17 00:00:00 2001 From: Jordan Layfield Date: Wed, 21 Oct 2026 11:16:40 +0100 Subject: [PATCH 1/4] Add BOM handling for region directives and corresponding tests NO_JIRA --- main/licence_headers.py | 12 ++++++++---- test/test_licence_headers.py | 8 ++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/main/licence_headers.py b/main/licence_headers.py index 93cba62..348187a 100644 --- a/main/licence_headers.py +++ b/main/licence_headers.py @@ -109,24 +109,28 @@ def _header_line_matches(actual, expected): return actual == expected +def _strip_bom(line): + return line.lstrip('\ufeff') + + def _is_region_directive(line): - return bool(re.match(r'^#region(?:\s|$)', line.strip())) + return bool(re.match(r'^#region(?:\s|$)', _strip_bom(line).strip())) def _is_end_region_directive(line): - return bool(re.match(r'^#endregion(?:\s|$)', line.strip())) + return bool(re.match(r'^#endregion(?:\s|$)', _strip_bom(line).strip())) def _find_leading_region_block(lines): for idx, line in enumerate(lines): - stripped = line.strip() + stripped = _strip_bom(line).strip() if not stripped: continue if not _is_region_directive(stripped): return None depth = 0 for region_idx in range(idx, len(lines)): - region_line = lines[region_idx].strip() + region_line = _strip_bom(lines[region_idx]).strip() if _is_region_directive(region_line): depth += 1 elif _is_end_region_directive(region_line): diff --git a/test/test_licence_headers.py b/test/test_licence_headers.py index d4a8b27..ffaf880 100644 --- a/test/test_licence_headers.py +++ b/test/test_licence_headers.py @@ -29,6 +29,14 @@ def test_header_inside_leading_region_wrapper_passes_without_duplication(): assert fixed == content +def test_bom_before_leading_region_wrapper_is_ignored(): + header = licence_headers._render_header('slash', year=2026) + content = '\ufeff#region Licence\n' + header + '#endregion\nprint("ok")\n' + assert licence_headers.check_content('example.cs', content, 2026) is None + fixed = licence_headers.fix_content('example.cs', content, 2026) + assert fixed == content + + def test_regional_keyword_is_not_treated_as_region_directive(): header = licence_headers._render_header('hash', year=2026) content = '#regional\n' + header + 'print("ok")\n' From 94494743ba7b40775cf5e142b7a4a1b47b71ab91 Mon Sep 17 00:00:00 2001 From: Jordan Layfield Date: Thu, 22 Oct 2026 15:25:25 +0100 Subject: [PATCH 2/4] Enhance header detection and repair logic for region-wrapped headers in C# and Python NO_JIRA --- README.md | 14 ++ main/licence_headers.py | 467 +++++++++++++++++++++-------------- test/test_licence_headers.py | 109 +++++++- 3 files changed, 402 insertions(+), 188 deletions(-) diff --git a/README.md b/README.md index 381a600..a249de6 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,20 @@ When licence header validation/formatting is enabled, files are filtered using t * Suffixes: `.designer.cs`, `.g.cs`, `.min.js`, `.lock` * Any file containing `.generated.` in its name +### Header Detection + +* The header may appear anywhere in the file's leading preamble (blank lines, comments, + region directives, and directives such as C# `using`/`#nullable`/`#pragma`, `#pragma once` + or `'use strict'`) before the first line of code. +* Matching ignores comment markers, whitespace, line wrapping and marker-only (`//` or `#`) + padding lines, and accepts any copyright year. +* Headers wrapped in regions are supported for C# (`#region`/`#endregion`) and Python + (`#region`/`# region`). Further languages can be added to `REGION_SYNTAX` in + `main/licence_headers.py`. +* In fix mode, damaged headers are repaired in place, duplicate headers are removed, and a + missing header is inserted inside a region labelled `Licence`/`License`/`Copyright`/`Header` + if one exists, otherwise at the top of the file (after any shebang or encoding line). + ## Recommended settings ### To ensure the line endings are correctly converted: 1. On Windows: `git config --global core.autocrlf true` diff --git a/main/licence_headers.py b/main/licence_headers.py index 348187a..10625a3 100644 --- a/main/licence_headers.py +++ b/main/licence_headers.py @@ -18,7 +18,9 @@ import argparse import codecs +from dataclasses import dataclass from datetime import datetime +from difflib import SequenceMatcher from io import BytesIO from pathlib import Path, PurePosixPath import re @@ -39,6 +41,61 @@ r'^(#|//) Copyright The Cambridge Crystallographic Data Centre ' r'\(CCDC\) \d{4}(?:, \d{4})?\r?\n?$' ) +COMMENT_MARKERS = {'hash': '#', 'slash': '//'} +YEAR_TOKEN = '{{ .Year }}' +YEAR_PATTERN = r'\d{4}(?:, \d{4})?' +IDENTITY_PREFIX = 'This code is' +IDENTITY_OWNER = 'Crystallographic Data Centre (CCDC)' +LICENCE_REGION_LABEL = re.compile(r'licen[cs]e|copyright|header', re.IGNORECASE) + +# Region open/close directives per file extension. To support another +# language, add its extension with (open pattern, close pattern); the open +# pattern may capture a "label" group used to find a licence region. +REGION_SYNTAX = { + '.cs': ( + re.compile(r'^\s*#region\b(?P