feat: replace hash// plugin with comment plugin that writes tags in each language's comment style
This commit is contained in:
parent
90047c22b5
commit
633012d1ef
6 changed files with 198 additions and 67 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ CONTEXTS = {
|
|||
|
||||
DEFAULT_PLUGINS = [
|
||||
'frontmatter',
|
||||
'hashdoubleslash',
|
||||
'comment',
|
||||
'id3',
|
||||
'office',
|
||||
'pdf'
|
||||
|
|
|
|||
103
orvanitag/plugins/comment.py
Normal file
103
orvanitag/plugins/comment.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
91
tests/test_comment.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue