diff --git a/README.md b/README.md index 3d3d204..937bfba 100644 --- a/README.md +++ b/README.md @@ -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, `` or `') +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 = ['#', '//', '--', '', '*/'] +# 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(''], - '.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) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5abfb94..6829042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -#// +orvanitag @orga #tagging #python +# otag: +orvanitag @orga #tagging #python [build-system] requires = ["setuptools>=77"] diff --git a/tests/test_comment.py b/tests/test_comment.py new file mode 100644 index 0000000..3318a0a --- /dev/null +++ b/tests/test_comment.py @@ -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', '\n', '#demo') + assert (cwd / 'p.php').read_text() == '\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', '\n\n', '#demo', 'home page')[0] == 0 + assert (cwd / 'i.html').read_text() == '\n\n\n\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()