feat: replace hash// plugin with comment plugin that writes tags in each language's comment style

This commit is contained in:
randogoth 2026-10-24 21:32:28 +03:00
parent 90047c22b5
commit 633012d1ef
6 changed files with 198 additions and 67 deletions

View file

@ -115,7 +115,7 @@ $ otag write id3 song.mp3
| `pdf` | `.pdf` | The keywords field |
| `office` | `.docx` `.xlsx` `.pptx` `.odt` `.ods` `.odp`, reading also `.doc` `.xls` `.ppt` | The description field |
| `frontmatter` | `.md` `.txt` `.markdown` `.rst` | `projects`, `contexts`, `tags` and `note` keys of the YAML front matter |
| `hash//` | Source code such as `.py` `.js` `.c` `.rs`, plus `.md` `.txt` | A `#// +project @context #tag note` line within the first five lines |
| `comment` | Source code such as `.py` `.sh` `.toml` `.js` `.c` `.go` `.rs` `.php` `.html` | An `otag: +project @context #tag note` comment line within the first ten lines, written in the file's comment style below any shebang, encoding, `<!DOCTYPE>` or `<?php` line |
### Import and Export
@ -150,4 +150,4 @@ types = [".mp3", ".flac"]
tags = "genre"
```
`plugins` limits which plugins run (`hash//` is listed as `hashdoubleslash`), `types` overrides the file types a plugin handles (`write` ignores types the plugin does not support), and `id3.map` additionally reads an audio tag (here `GENRE`) into a category.
`plugins` limits which plugins run, `types` overrides the file types a plugin handles (`write` ignores types the plugin does not support), and `id3.map` additionally reads an audio tag (here `GENRE`) into a category.

View file

@ -22,7 +22,7 @@ CONTEXTS = {
DEFAULT_PLUGINS = [
'frontmatter',
'hashdoubleslash',
'comment',
'id3',
'office',
'pdf'

View file

@ -0,0 +1,103 @@
"""
Tags in a source file as a single comment line near the top, like `# otag: +project @context #tag note`.
"""
import os
import re
import shutil
NAME = 'comment'
MARKER = 'otag:'
LINES = 10 # how far from the top the tag line is looked for
HASH = ('# ', '')
SLASH = ('// ', '')
DASH = ('-- ', '')
HTML = ('<!-- ', ' -->')
STYLES = {
'.c' : SLASH,
'.cgi' : HASH,
'.cpp' : SLASH,
'.cs' : SLASH,
'.go' : SLASH,
'.h' : SLASH,
'.hs' : DASH,
'.html' : HTML,
'.java' : SLASH,
'.js' : SLASH,
'.kt' : SLASH,
'.php' : SLASH,
'.pl' : HASH,
'.py' : HASH,
'.rb' : HASH,
'.rs' : SLASH,
'.sh' : HASH,
'.toml' : HASH,
'.ts' : SLASH,
}
TYPES = list(STYLES)
# Comment openings a tag line may start with, and the closings it may end with
OPENINGS = ['#', '//', '--', '<!--', '/*']
CLOSINGS = ['-->', '*/']
# Python's encoding declaration only works in the first two lines (PEP 263)
CODING = re.compile(r'^[ \t\f]*#.*?coding[:=]')
def find(lines: list):
"""Returns the index and the contexts of the tag line, or (None, None)"""
for num, line in enumerate(lines[:LINES]):
opening, marker, contexts = line.strip().partition(MARKER)
if marker and opening.strip() in OPENINGS:
for closing in CLOSINGS:
contexts = contexts.strip().removesuffix(closing)
return num, contexts.strip()
return None, None
def header(lines: list, suffix: str) -> int:
"""Returns how many leading lines have to stay above the tag line"""
count = 0
if lines and (lines[0].startswith('#!') or lines[0].lower().startswith('<!doctype')):
count = 1
if suffix == '.php':
# Outside of the PHP block the comment would be sent as output
if not lines or lines[0].strip() != '<?php':
raise ValueError('PHP files need `<?php` on a line of its own at the top')
count = 1
while count < 2 and count < len(lines) and CODING.match(lines[count]):
count += 1
return count
def reader(o):
with o.file.open(encoding='utf-8', errors='replace') as data:
num, contexts = find([next(data, '') for _ in range(LINES)])
if contexts:
return o.context(o.string(contexts))
def writer(o):
with o.file.open(encoding='utf-8', newline='') as data: # keeps \r\n line endings
text = data.read()
lines = text.splitlines(keepends=True)
newline = '\r\n' if '\r\n' in text else '\n'
opening, closing = STYLES[o.file.suffix.lower()]
line = f'{opening}{MARKER} {" ".join(o.collect(o.context))}{closing}{newline}'
num, contexts = find(lines)
if num is not None:
lines[num] = line
else:
num = header(lines, o.file.suffix.lower())
# A blank line keeps the tag from becoming a doc comment, e.g. of a Go package
blank = [] if num < len(lines) and not lines[num].strip() else [newline]
lines[num:num] = [line] + blank
tmp = o.file.with_name(f'.{o.file.name}.tmp')
try:
with open(tmp, 'w', encoding='utf-8', newline='') as data:
data.write(''.join(lines))
shutil.copymode(o.file, tmp)
os.replace(tmp, o.file)
finally:
tmp.unlink(missing_ok=True)

View file

@ -1,63 +0,0 @@
import re
from pathlib import Path
NAME = 'hash//'
REGEX = r'\s*([@+]\w+|#\w+(?<!#//))'
PREFIX = '#// '
MLC_C = ['/*', '*/']
MLC_Python = ['"""', '"""']
MLC_Perl = ['=begin', '=end']
MAP = {
'.c' : MLC_C,
'.cgi' : MLC_Perl,
'.pl' : MLC_Perl,
'.class' : MLC_C,
'.cpp' : MLC_C,
'.cs' : MLC_C,
'.go' : MLC_C,
'.h' : MLC_C,
'.hs' : ['{-', '-}'],
'.html' : ['<!--', '-->'],
'.java' : MLC_C,
'.js' : MLC_C,
'.kt' : MLC_C,
'.md' : ['---', '---'],
'.php' : MLC_C,
'.py' : MLC_Python,
'.rb' : MLC_Perl,
'.rs' : MLC_C,
'.ts' : MLC_C,
'.txt' : ['---', '---'],
}
TYPES = MAP.keys()
def reader(o):
with o.file.open() as data:
for line_num, line in enumerate(data, start=1):
if line_num > 5:
break
if line.strip().startswith("#// "):
argv = o.string(line.strip()[3:])
context = o.context(argv)
return context
def writer(o):
string = PREFIX + ' '.join(o.collect(o.context)) + '\n'
with o.file.open() as data:
content = data.readlines()
num = 0
line = ''
if len(content):
for num, line in enumerate(content):
if line.strip().startswith("#// ") or num > 3:
break
if not num > 3 and line.strip().startswith("#// "):
content[num] = string
with o.file.open('w') as file:
file.writelines(content)
else:
frontmatter = MAP[o.file.suffix][0] + '\n' + string + MAP[o.file.suffix][1] + '\n'
with o.file.open('r+') as file:
content = file.read()
file.seek(0, 0)
file.write(frontmatter + content)

View file

@ -1,4 +1,4 @@
#// +orvanitag @orga #tagging #python
# otag: +orvanitag @orga #tagging #python
[build-system]
requires = ["setuptools>=77"]

91
tests/test_comment.py Normal file
View file

@ -0,0 +1,91 @@
import os
import shutil
import subprocess
import sys
import pytest
import orvanitag.orvani as orvani
from orvanitag.orvani import OrvaniTag
@pytest.fixture
def cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
return tmp_path
def write(name, content, *tags):
with open(name, 'w', newline='') as file:
file.write(content)
OrvaniTag().add(name, *tags)
return OrvaniTag().write_metadata(name, 'comment')
def read_back(cwd, name):
(cwd / orvani.CONTEXT_FILE).unlink()
OrvaniTag().read_metadata(name)
return (cwd / orvani.CONTEXT_FILE).read_text()
def test_python_keeps_shebang_coding_and_docstring(cwd):
source = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n"""Real docstring."""\nprint(__doc__)\n'
(cwd / 's.py').write_text(source)
os.chmod(cwd / 's.py', 0o755)
assert write('s.py', source, '+tools', '#demo', 'a note')[0] == 0
assert (cwd / 's.py').read_text() == (
'#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n# otag: +tools #demo a note\n\n'
'"""Real docstring."""\nprint(__doc__)\n'
)
assert os.stat(cwd / 's.py').st_mode & 0o111
run = subprocess.run([sys.executable, 's.py'], capture_output=True, text=True)
assert run.stdout == 'Real docstring.\n'
assert read_back(cwd, 's.py') == 's.py --- +tools #demo a note\n'
@pytest.mark.skipif(not shutil.which('perl'), reason='perl not installed')
def test_perl_still_runs(cwd):
assert write('s.pl', 'print "perl ok\\n";\n', '#demo')[0] == 0
run = subprocess.run(['perl', 's.pl'], capture_output=True, text=True)
assert run.stdout == 'perl ok\n'
def test_php_tag_goes_inside_the_php_block(cwd):
assert write('p.php', '<?php\necho "ok";\n', '#demo')[0] == 0
assert (cwd / 'p.php').read_text() == '<?php\n// otag: #demo\n\necho "ok";\n'
def test_php_without_own_opening_line_is_refused(cwd):
with pytest.raises(ValueError):
write('p.php', '<?php echo "ok"; ?>\n', '#demo')
assert (cwd / 'p.php').read_text() == '<?php echo "ok"; ?>\n'
def test_go_tag_is_not_a_package_doc_comment(cwd):
assert write('m.go', 'package main\n', '#demo')[0] == 0
assert (cwd / 'm.go').read_text() == '// otag: #demo\n\npackage main\n'
def test_html_keeps_doctype_first(cwd):
assert write('i.html', '<!DOCTYPE html>\n<html></html>\n', '#demo', 'home page')[0] == 0
assert (cwd / 'i.html').read_text() == '<!DOCTYPE html>\n<!-- otag: #demo home page -->\n\n<html></html>\n'
assert read_back(cwd, 'i.html') == 'i.html --- #demo home page\n'
def test_existing_tag_line_is_replaced_and_crlf_kept(cwd):
source = '// otag: #old\r\n\r\nint main() {}\r\n'
assert write('m.c', source, '#new')[0] == 0
assert (cwd / 'm.c').read_bytes() == b'// otag: #new\r\n\r\nint main() {}\r\n'
def test_reads_block_comment_and_toml(cwd):
(cwd / 'a.js').write_text('/* otag: +site #ui */\nlet a = 1\n')
(cwd / 'pyproject.toml').write_text('# otag: +orvanitag @orga\n[project]\n')
OrvaniTag().scan('.', False)
assert (cwd / orvani.CONTEXT_FILE).read_text() == 'a.js --- +site #ui\npyproject.toml --- +orvanitag @orga\n'
def test_marker_inside_code_is_ignored(cwd):
(cwd / 'a.py').write_text('x = "otag: #nope"\n')
OrvaniTag().scan('.', False)
assert not (cwd / orvani.CONTEXT_FILE).exists()