csdoc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csdoc/__init__.py +0 -0
- csdoc/cli.py +60 -0
- csdoc/generator/__init__.py +0 -0
- csdoc/generator/builder.py +63 -0
- csdoc/generator/templates/base.html +82 -0
- csdoc/generator/templates/index.html +87 -0
- csdoc/models.py +46 -0
- csdoc/parser/__init__.py +0 -0
- csdoc/parser/csound.py +205 -0
- csdoc/parser/docblock.py +126 -0
- csdoc-0.1.0.dist-info/METADATA +109 -0
- csdoc-0.1.0.dist-info/RECORD +14 -0
- csdoc-0.1.0.dist-info/WHEEL +4 -0
- csdoc-0.1.0.dist-info/entry_points.txt +2 -0
csdoc/__init__.py
ADDED
|
File without changes
|
csdoc/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .parser.csound import CsoundParser
|
|
5
|
+
from .generator.builder import SiteBuilder
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
@app.command()
|
|
10
|
+
def build(
|
|
11
|
+
source: Path = typer.Argument(..., exists=True, help="The Csound source file (e.g. main.csd or .orc)"),
|
|
12
|
+
output: Path = typer.Option(Path("dist"), "--output", "-o", help="Output directory for the documentation site")
|
|
13
|
+
):
|
|
14
|
+
"""
|
|
15
|
+
Build documentation for a Csound project.
|
|
16
|
+
"""
|
|
17
|
+
typer.echo(f"Parsing project from {source}...")
|
|
18
|
+
|
|
19
|
+
parser = CsoundParser()
|
|
20
|
+
try:
|
|
21
|
+
entries = parser.parse_project(str(source))
|
|
22
|
+
except Exception as e:
|
|
23
|
+
typer.echo(f"Error parsing project: {e}", err=True)
|
|
24
|
+
raise typer.Exit(code=1)
|
|
25
|
+
|
|
26
|
+
typer.echo(f"Found {len(entries)} documented entries.")
|
|
27
|
+
|
|
28
|
+
typer.echo(f"Building site in {output}...")
|
|
29
|
+
# Use the parent directory of the source as the base path for relativization
|
|
30
|
+
base_path = str(source.resolve().parent)
|
|
31
|
+
builder = SiteBuilder(str(output), base_path=base_path)
|
|
32
|
+
builder.build(entries)
|
|
33
|
+
|
|
34
|
+
typer.echo("Done!")
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def json(
|
|
38
|
+
source: Path = typer.Argument(..., exists=True, help="The Csound source file"),
|
|
39
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output JSON file")
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Export parsed documentation as JSON.
|
|
43
|
+
"""
|
|
44
|
+
import json
|
|
45
|
+
import dataclasses
|
|
46
|
+
|
|
47
|
+
parser = CsoundParser()
|
|
48
|
+
entries = parser.parse_project(str(source))
|
|
49
|
+
|
|
50
|
+
data = [dataclasses.asdict(e) for e in entries]
|
|
51
|
+
|
|
52
|
+
if output:
|
|
53
|
+
with open(output, 'w') as f:
|
|
54
|
+
json.dump(data, f, indent=2)
|
|
55
|
+
typer.echo(f"JSON written to {output}")
|
|
56
|
+
else:
|
|
57
|
+
typer.echo(json.dumps(data, indent=2))
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from typing import List, Optional, Dict, Tuple
|
|
4
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
5
|
+
import markdown
|
|
6
|
+
from ..models import DocEntry
|
|
7
|
+
|
|
8
|
+
class SiteBuilder:
|
|
9
|
+
def __init__(self, output_dir: str, base_path: Optional[str] = None):
|
|
10
|
+
self.output_dir = output_dir
|
|
11
|
+
self.base_path = base_path or os.getcwd()
|
|
12
|
+
self.template_dir = os.path.join(os.path.dirname(__file__), 'templates')
|
|
13
|
+
self.env = Environment(
|
|
14
|
+
loader=FileSystemLoader(self.template_dir),
|
|
15
|
+
autoescape=select_autoescape(['html', 'xml'])
|
|
16
|
+
)
|
|
17
|
+
# Register markdown filter
|
|
18
|
+
self.env.filters['markdown'] = lambda text: markdown.markdown(text) if text else ""
|
|
19
|
+
|
|
20
|
+
def build(self, entries: List[DocEntry]):
|
|
21
|
+
"""
|
|
22
|
+
Builds the documentation site.
|
|
23
|
+
"""
|
|
24
|
+
if not os.path.exists(self.output_dir):
|
|
25
|
+
os.makedirs(self.output_dir)
|
|
26
|
+
|
|
27
|
+
# Relativize paths for display
|
|
28
|
+
for entry in entries:
|
|
29
|
+
if os.path.isabs(entry.file_path):
|
|
30
|
+
entry.file_path = os.path.relpath(entry.file_path, self.base_path)
|
|
31
|
+
|
|
32
|
+
# Group entries by name and type (for overloads)
|
|
33
|
+
# We group by (name, type) so that an instrument and UDO with same name are separate
|
|
34
|
+
grouped: Dict[Tuple[str, str], List[DocEntry]] = {}
|
|
35
|
+
for entry in entries:
|
|
36
|
+
key = (entry.name, type(entry).__name__)
|
|
37
|
+
if key not in grouped:
|
|
38
|
+
grouped[key] = []
|
|
39
|
+
grouped[key].append(entry)
|
|
40
|
+
|
|
41
|
+
# Convert to a list for Jinja2
|
|
42
|
+
entry_groups = []
|
|
43
|
+
for (name, entry_type), items in grouped.items():
|
|
44
|
+
entry_groups.append({
|
|
45
|
+
"name": name,
|
|
46
|
+
"type": entry_type.replace('Entry', ''),
|
|
47
|
+
"entries": items
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
template = self.env.get_template('index.html')
|
|
51
|
+
|
|
52
|
+
# Collect all entry names for type linking
|
|
53
|
+
documented_names = {entry.name for entry in entries}
|
|
54
|
+
|
|
55
|
+
output_content = template.render(
|
|
56
|
+
entry_groups=entry_groups,
|
|
57
|
+
documented_names=documented_names
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
with open(os.path.join(self.output_dir, 'index.html'), 'w', encoding='utf-8') as f:
|
|
61
|
+
f.write(output_content)
|
|
62
|
+
|
|
63
|
+
print(f"Documentation generated in {self.output_dir}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{% block title %}csdoc{% endblock %}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--primary-color: #007bff;
|
|
10
|
+
--bg-color: #ffffff;
|
|
11
|
+
--text-color: #333333;
|
|
12
|
+
--sidebar-bg: #f8f9fa;
|
|
13
|
+
--border-color: #dee2e6;
|
|
14
|
+
}
|
|
15
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: var(--text-color); display: flex; height: 100vh; }
|
|
16
|
+
|
|
17
|
+
nav { width: 280px; background: var(--sidebar-bg); border-right: 1px solid var(--border-color); overflow-y: auto; padding: 20px; flex-shrink: 0; }
|
|
18
|
+
nav h3 { margin-top: 0; font-size: 1.2rem; }
|
|
19
|
+
nav ul { list-style: none; padding: 0; }
|
|
20
|
+
nav li { margin-bottom: 8px; }
|
|
21
|
+
nav a { text-decoration: none; color: var(--text-color); display: block; padding: 4px 8px; border-radius: 4px; }
|
|
22
|
+
nav a:hover { background: rgba(0,0,0,0.05); color: var(--primary-color); }
|
|
23
|
+
|
|
24
|
+
main { flex: 1; padding: 40px; overflow-y: auto; }
|
|
25
|
+
|
|
26
|
+
h1, h2, h3, h4, h5, h6 { margin-top: 0; color: #222; }
|
|
27
|
+
h1 { border-bottom: 2px solid var(--border-color); padding-bottom: 10px; margin-bottom: 30px; }
|
|
28
|
+
|
|
29
|
+
.doc-entry { margin-bottom: 50px; padding-bottom: 30px; border-bottom: 1px solid var(--border-color); }
|
|
30
|
+
.doc-entry:last-child { border-bottom: none; }
|
|
31
|
+
|
|
32
|
+
.signature { font-family: monospace; background: #f1f3f5; padding: 10px; border-radius: 4px; margin: 10px 0 20px 0; overflow-x: auto; }
|
|
33
|
+
|
|
34
|
+
.tag-list { list-style: none; padding: 0; }
|
|
35
|
+
.tag-list li { margin-bottom: 8px; }
|
|
36
|
+
.tag-name { font-weight: 600; color: #495057; display: inline-block; min-width: 100px; }
|
|
37
|
+
.tag-type { font-family: monospace; color: #e83e8c; background: rgba(232, 62, 140, 0.1); padding: 2px 4px; border-radius: 3px; font-size: 0.9em; margin-right: 5px; }
|
|
38
|
+
|
|
39
|
+
code { font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: rgba(0,0,0,0.04); padding: 2px 4px; border-radius: 3px; }
|
|
40
|
+
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
|
41
|
+
pre code { background: none; color: inherit; padding: 0; }
|
|
42
|
+
|
|
43
|
+
/* Badges */
|
|
44
|
+
.badge {
|
|
45
|
+
display: inline-block;
|
|
46
|
+
padding: 2px 6px;
|
|
47
|
+
font-size: 0.75em;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
line-height: 1;
|
|
50
|
+
text-align: center;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
vertical-align: baseline;
|
|
53
|
+
border-radius: 4px;
|
|
54
|
+
text-transform: uppercase;
|
|
55
|
+
margin-left: 5px;
|
|
56
|
+
}
|
|
57
|
+
.badge-udo { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
|
58
|
+
.badge-instrument { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
59
|
+
.badge-struct { background-color: #ffe5d0; color: #854d0e; border: 1px solid #fbd6b1; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<nav>
|
|
64
|
+
<h3>Contents</h3>
|
|
65
|
+
<ul>
|
|
66
|
+
{% for group in entry_groups %}
|
|
67
|
+
<li>
|
|
68
|
+
<a href="#{{ group.name }}" style="display: flex; justify-content: space-between; align-items: center;">
|
|
69
|
+
<span>{{ group.name }}</span>
|
|
70
|
+
<span class="badge badge-{% if group.type == 'UDO' %}udo{% elif group.type == 'Instrument' %}instrument{% else %}struct{% endif %}">
|
|
71
|
+
{{ group.type }}
|
|
72
|
+
</span>
|
|
73
|
+
</a>
|
|
74
|
+
</li>
|
|
75
|
+
{% endfor %}
|
|
76
|
+
</ul>
|
|
77
|
+
</nav>
|
|
78
|
+
<main>
|
|
79
|
+
{% block content %}{% endblock %}
|
|
80
|
+
</main>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<h1>Project Documentation</h1>
|
|
5
|
+
|
|
6
|
+
{% for group in entry_groups %}
|
|
7
|
+
<div class="doc-entry" id="{{ group.name }}">
|
|
8
|
+
<h2>
|
|
9
|
+
{{ group.name }}
|
|
10
|
+
<span class="badge badge-{% if group.type == 'UDO' %}udo{% elif group.type == 'Instrument' %}instrument{% else %}struct{% endif %}">
|
|
11
|
+
{{ group.type }}
|
|
12
|
+
</span>
|
|
13
|
+
</h2>
|
|
14
|
+
|
|
15
|
+
{# Handle multiple signatures for overloads #}
|
|
16
|
+
{% for entry in group.entries %}
|
|
17
|
+
<div class="overload-item">
|
|
18
|
+
<div class="signature">
|
|
19
|
+
{% if group.type == 'UDO' %}
|
|
20
|
+
{# Functional Form Only #}
|
|
21
|
+
<div>
|
|
22
|
+
{% if entry.returns %}
|
|
23
|
+
{% for ret in entry.returns %}{{ ret.name or '?' }}{% if not loop.last %}, {% endif %}{% endfor %} =
|
|
24
|
+
{% elif entry.outputs %}
|
|
25
|
+
{{ entry.outputs }} =
|
|
26
|
+
{% endif %}
|
|
27
|
+
<strong>{{ entry.name }}</strong>({% for param in entry.params %}{{ param.name or '?' }}{% if not loop.last %}, {% endif %}{% endfor %})
|
|
28
|
+
</div>
|
|
29
|
+
{% else %}
|
|
30
|
+
{# Instrument / Struct form (simple) #}
|
|
31
|
+
{% if entry.outputs %}{{ entry.outputs }} {% endif %}
|
|
32
|
+
<strong>{{ entry.name }}</strong>
|
|
33
|
+
{% if entry.inputs %} {{ entry.inputs }}{% endif %}
|
|
34
|
+
{% endif %}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="description">
|
|
38
|
+
{{ entry.description | markdown | safe }}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{% if entry.params %}
|
|
42
|
+
<h3>Parameters</h3>
|
|
43
|
+
<ul class="tag-list">
|
|
44
|
+
{% for param in entry.params %}
|
|
45
|
+
<li>
|
|
46
|
+
<span class="tag-name">{{ param.name }}</span>
|
|
47
|
+
{% if param.type_info %}
|
|
48
|
+
{% if param.type_info in documented_names %}
|
|
49
|
+
<a href="#{{ param.type_info }}" class="tag-type">{{ param.type_info }}</a>
|
|
50
|
+
{% else %}
|
|
51
|
+
<span class="tag-type">{{ param.type_info }}</span>
|
|
52
|
+
{% endif %}
|
|
53
|
+
{% endif %}
|
|
54
|
+
<span class="tag-desc">{{ param.description }}</span>
|
|
55
|
+
</li>
|
|
56
|
+
{% endfor %}
|
|
57
|
+
</ul>
|
|
58
|
+
{% endif %}
|
|
59
|
+
|
|
60
|
+
{% if entry.returns %}
|
|
61
|
+
<h3>Returns</h3>
|
|
62
|
+
<ul class="tag-list">
|
|
63
|
+
{% for ret in entry.returns %}
|
|
64
|
+
<li>
|
|
65
|
+
<span class="tag-name">{% if ret.name %}{{ ret.name }}{% else %}return{% endif %}</span>
|
|
66
|
+
{% if ret.type_info %}
|
|
67
|
+
{% if ret.type_info in documented_names %}
|
|
68
|
+
<a href="#{{ ret.type_info }}" class="tag-type">{{ ret.type_info }}</a>
|
|
69
|
+
{% else %}
|
|
70
|
+
<span class="tag-type">{{ ret.type_info }}</span>
|
|
71
|
+
{% endif %}
|
|
72
|
+
{% endif %}
|
|
73
|
+
<span class="tag-desc">{{ ret.description }}</span>
|
|
74
|
+
</li>
|
|
75
|
+
{% endfor %}
|
|
76
|
+
</ul>
|
|
77
|
+
{% endif %}
|
|
78
|
+
|
|
79
|
+
<p style="font-size: 0.8em; color: #999; margin-top: 10px;">
|
|
80
|
+
Defined in <code>{{ entry.file_path }}</code> at line {{ entry.line_number }}
|
|
81
|
+
</p>
|
|
82
|
+
{% if not loop.last %}<hr style="border: 0; border-top: 1px dashed #eee; margin: 20px 0;">{% endif %}
|
|
83
|
+
</div>
|
|
84
|
+
{% endfor %}
|
|
85
|
+
</div>
|
|
86
|
+
{% endfor %}
|
|
87
|
+
{% endblock %}
|
csdoc/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class DocParam:
|
|
6
|
+
"""Represents a @param tag"""
|
|
7
|
+
name: str = ""
|
|
8
|
+
description: str = ""
|
|
9
|
+
type_info: str = "" # e.g. from {type} if supported, or inferred
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DocReturn:
|
|
13
|
+
"""Represents a @return tag"""
|
|
14
|
+
name: str = ""
|
|
15
|
+
type_info: str = ""
|
|
16
|
+
description: str = ""
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DocEntry:
|
|
20
|
+
"""Base class for all documented entities"""
|
|
21
|
+
name: str = ""
|
|
22
|
+
description: str = "" # Main description from the docblock
|
|
23
|
+
line_number: int = 0
|
|
24
|
+
file_path: str = ""
|
|
25
|
+
params: List[DocParam] = field(default_factory=list)
|
|
26
|
+
returns: List[DocReturn] = field(default_factory=list)
|
|
27
|
+
raw_docblock: str = "" # The original comment block
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class UDOEntry(DocEntry):
|
|
31
|
+
"""Represents a User Defined Opcode"""
|
|
32
|
+
inputs: str = "" # The input string, e.g. "a, k"
|
|
33
|
+
outputs: str = "" # The output string, e.g. "a"
|
|
34
|
+
arg_names: List[str] = field(default_factory=list) # Extracted argument names
|
|
35
|
+
deprecated: bool = False
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class InstrumentEntry(DocEntry):
|
|
39
|
+
"""Represents a Csound Instrument"""
|
|
40
|
+
id: str = "" # Can be number or name
|
|
41
|
+
# Instruments might have p-fields described in params
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class StructEntry(DocEntry):
|
|
45
|
+
"""Represents a typedef/struct"""
|
|
46
|
+
members: List[str] = field(default_factory=list)
|
csdoc/parser/__init__.py
ADDED
|
File without changes
|
csdoc/parser/csound.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Set, Optional
|
|
4
|
+
from .docblock import parse_docblock
|
|
5
|
+
from ..models import DocEntry, UDOEntry, InstrumentEntry, StructEntry, DocParam, DocReturn
|
|
6
|
+
|
|
7
|
+
class CsoundParser:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.entries: List[DocEntry] = []
|
|
10
|
+
self.visited_files: Set[str] = set()
|
|
11
|
+
|
|
12
|
+
def parse_project(self, root_file: str) -> List[DocEntry]:
|
|
13
|
+
"""
|
|
14
|
+
Parses a Csound project starting from a root file (e.g. .csd or .orc).
|
|
15
|
+
"""
|
|
16
|
+
self.entries = []
|
|
17
|
+
self.visited_files = set()
|
|
18
|
+
|
|
19
|
+
abs_path = os.path.abspath(root_file)
|
|
20
|
+
if not os.path.exists(abs_path):
|
|
21
|
+
raise FileNotFoundError(f"File not found: {root_file}")
|
|
22
|
+
|
|
23
|
+
self._parse_file(abs_path)
|
|
24
|
+
return self.entries
|
|
25
|
+
|
|
26
|
+
def _parse_file(self, file_path: str):
|
|
27
|
+
if file_path in self.visited_files:
|
|
28
|
+
return
|
|
29
|
+
self.visited_files.add(file_path)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"Warning: Could not read {file_path}: {e}")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# 1. Handle Includes
|
|
39
|
+
# Regex for #include "..." or #include <...>
|
|
40
|
+
# Csound usually uses #include "file"
|
|
41
|
+
include_iter = re.finditer(r'^\s*#include\s+"([^"]+)"', content, re.MULTILINE)
|
|
42
|
+
for match in include_iter:
|
|
43
|
+
inc_path = match.group(1)
|
|
44
|
+
# Resolve path relative to current file
|
|
45
|
+
current_dir = os.path.dirname(file_path)
|
|
46
|
+
full_inc_path = os.path.join(current_dir, inc_path)
|
|
47
|
+
self._parse_file(os.path.abspath(full_inc_path))
|
|
48
|
+
|
|
49
|
+
# 2. Parse DocBlocks and Entries
|
|
50
|
+
# We assume docblocks are /** ... */
|
|
51
|
+
# We iterate through the file looking for docblocks.
|
|
52
|
+
# When we find one, we look ahead for the definition.
|
|
53
|
+
|
|
54
|
+
# Regex for docblock
|
|
55
|
+
docblock_pattern = re.compile(r'/\*\*(.*?)\*/', re.DOTALL)
|
|
56
|
+
|
|
57
|
+
last_pos = 0
|
|
58
|
+
matches = list(docblock_pattern.finditer(content))
|
|
59
|
+
|
|
60
|
+
for m in matches:
|
|
61
|
+
block_content = m.group(1)
|
|
62
|
+
end_pos = m.end()
|
|
63
|
+
|
|
64
|
+
# Find the line number of the start of the block
|
|
65
|
+
# (Crude approximation or accurate calculation)
|
|
66
|
+
preceeding_text = content[:m.start()]
|
|
67
|
+
line_number = preceeding_text.count('\n') + 1
|
|
68
|
+
|
|
69
|
+
# Look ahead for the next definition
|
|
70
|
+
# We look from end_pos until we find a definition or another docblock or end of file
|
|
71
|
+
# Actually, just look for the *next* definition pattern.
|
|
72
|
+
# If there is a huge gap or another docblock, maybe it's a detached comment?
|
|
73
|
+
# But standard behavior: take the *first* matching declaration after the comment.
|
|
74
|
+
# We should probably limit the search distance or stop at next comment.
|
|
75
|
+
|
|
76
|
+
# Let's get the text chunk after the comment
|
|
77
|
+
chunk_start = end_pos
|
|
78
|
+
# Use a limited chunk to avoid scanning whole file? Or just regex search from pos
|
|
79
|
+
|
|
80
|
+
# Regexes for definitions
|
|
81
|
+
# 1. Old-style: opcode name, out, in
|
|
82
|
+
opcode_old_re = re.compile(r'\bopcode\b\s+([\w\d_]+)\s*,\s*(\S+)\s*,\s*(\S+)')
|
|
83
|
+
# 2. New-style (Csound 7): opcode name(arg list):ret types
|
|
84
|
+
# Example: opcode MyOp(k1:k, i1:i):k
|
|
85
|
+
opcode_new_re = re.compile(r'\bopcode\b\s+([\w\d_]+)\s*\((.*?)\)\s*(?::\s*([ \t\w\d_,]+))?')
|
|
86
|
+
|
|
87
|
+
# instr name
|
|
88
|
+
instr_re = re.compile(r'\binstr\b\s+([\w\d_]+)')
|
|
89
|
+
# struct name
|
|
90
|
+
struct_re = re.compile(r'\bstruct\b\s+(\w+)')
|
|
91
|
+
|
|
92
|
+
# Search in the remaining content
|
|
93
|
+
search_text = content[chunk_start:]
|
|
94
|
+
next_docblock = docblock_pattern.search(search_text)
|
|
95
|
+
limit_pos = next_docblock.start() if next_docblock else len(search_text)
|
|
96
|
+
candidate_text = search_text[:limit_pos]
|
|
97
|
+
|
|
98
|
+
# Find the earliest match
|
|
99
|
+
matches_found = []
|
|
100
|
+
m_opcode_old = opcode_old_re.search(candidate_text)
|
|
101
|
+
if m_opcode_old: matches_found.append((m_opcode_old.start(), 'opcode_old', m_opcode_old))
|
|
102
|
+
|
|
103
|
+
m_opcode_new = opcode_new_re.search(candidate_text)
|
|
104
|
+
if m_opcode_new: matches_found.append((m_opcode_new.start(), 'opcode_new', m_opcode_new))
|
|
105
|
+
|
|
106
|
+
m_instr = instr_re.search(candidate_text)
|
|
107
|
+
if m_instr: matches_found.append((m_instr.start(), 'instr', m_instr))
|
|
108
|
+
|
|
109
|
+
m_struct = struct_re.search(candidate_text)
|
|
110
|
+
if m_struct: matches_found.append((m_struct.start(), 'struct', m_struct))
|
|
111
|
+
|
|
112
|
+
matches_found.sort(key=lambda x: x[0])
|
|
113
|
+
|
|
114
|
+
if not matches_found:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
match_type = matches_found[0][1]
|
|
118
|
+
match_obj = matches_found[0][2]
|
|
119
|
+
desc, params, returns = parse_docblock(block_content)
|
|
120
|
+
|
|
121
|
+
entry = None
|
|
122
|
+
if match_type.startswith('opcode'):
|
|
123
|
+
if match_type == 'opcode_old':
|
|
124
|
+
name = match_obj.group(1)
|
|
125
|
+
outs = match_obj.group(2)
|
|
126
|
+
ins = match_obj.group(3)
|
|
127
|
+
else: # opcode_new
|
|
128
|
+
name = match_obj.group(1)
|
|
129
|
+
arg_list = match_obj.group(2)
|
|
130
|
+
ret_list = match_obj.group(3) or ""
|
|
131
|
+
|
|
132
|
+
# Convert arg_list (e.g. "a1:a, k1:k") to types string (e.g. "ak")
|
|
133
|
+
# Or if it's just "a1, k1", we can't really know unless we parse types.
|
|
134
|
+
# Common Csound 7 style often omits types if they are default, but
|
|
135
|
+
# usually they are explicit in UDOs.
|
|
136
|
+
# If they are just names, maybe we just list names?
|
|
137
|
+
# Let's try to extract types if present.
|
|
138
|
+
in_types = []
|
|
139
|
+
arg_names = []
|
|
140
|
+
for arg in arg_list.split(','):
|
|
141
|
+
arg = arg.strip()
|
|
142
|
+
if not arg:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Extract name and type
|
|
146
|
+
if ':' in arg:
|
|
147
|
+
parts = arg.split(':')
|
|
148
|
+
name = parts[0].strip()
|
|
149
|
+
type_ = parts[-1].strip()
|
|
150
|
+
else:
|
|
151
|
+
name = arg
|
|
152
|
+
# Heuristic: first character of name?
|
|
153
|
+
type_ = arg[0] if arg else ""
|
|
154
|
+
|
|
155
|
+
arg_names.append(name)
|
|
156
|
+
in_types.append(type_)
|
|
157
|
+
|
|
158
|
+
out_types = []
|
|
159
|
+
for ret in ret_list.split(','):
|
|
160
|
+
ret = ret.strip()
|
|
161
|
+
if ret:
|
|
162
|
+
out_types.append(ret)
|
|
163
|
+
|
|
164
|
+
ins = "".join(in_types)
|
|
165
|
+
outs = "".join(out_types)
|
|
166
|
+
|
|
167
|
+
entry = UDOEntry(
|
|
168
|
+
name=name,
|
|
169
|
+
description=desc,
|
|
170
|
+
params=params,
|
|
171
|
+
returns=returns,
|
|
172
|
+
inputs=ins,
|
|
173
|
+
outputs=outs,
|
|
174
|
+
arg_names=arg_names if match_type != 'opcode_old' else [],
|
|
175
|
+
line_number=line_number,
|
|
176
|
+
file_path=file_path,
|
|
177
|
+
raw_docblock=block_content
|
|
178
|
+
)
|
|
179
|
+
elif match_type == 'instr':
|
|
180
|
+
name = match_obj.group(1)
|
|
181
|
+
entry = InstrumentEntry(
|
|
182
|
+
name=name, # name field in DocEntry
|
|
183
|
+
id=name,
|
|
184
|
+
description=desc,
|
|
185
|
+
params=params,
|
|
186
|
+
returns=returns,
|
|
187
|
+
line_number=line_number,
|
|
188
|
+
file_path=file_path,
|
|
189
|
+
raw_docblock=block_content
|
|
190
|
+
)
|
|
191
|
+
elif match_type == 'struct':
|
|
192
|
+
name = match_obj.group(1)
|
|
193
|
+
entry = StructEntry(
|
|
194
|
+
name=name,
|
|
195
|
+
description=desc,
|
|
196
|
+
params=params, # struct params? Members?
|
|
197
|
+
returns=returns,
|
|
198
|
+
line_number=line_number,
|
|
199
|
+
file_path=file_path,
|
|
200
|
+
raw_docblock=block_content
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if entry:
|
|
204
|
+
self.entries.append(entry)
|
|
205
|
+
|
csdoc/parser/docblock.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, List, Tuple
|
|
3
|
+
from ..models import DocParam, DocReturn
|
|
4
|
+
|
|
5
|
+
def parse_docblock(content: str) -> Tuple[str, List[DocParam], List[DocReturn]]:
|
|
6
|
+
"""
|
|
7
|
+
Parses the content of a docblock (inside /** ... */).
|
|
8
|
+
Returns (description, params, returns)
|
|
9
|
+
"""
|
|
10
|
+
lines = content.splitlines()
|
|
11
|
+
cleaned_lines = []
|
|
12
|
+
|
|
13
|
+
# Strip leading * from each line
|
|
14
|
+
for line in lines:
|
|
15
|
+
stripped = line.strip()
|
|
16
|
+
if stripped.startswith('*'):
|
|
17
|
+
# Remove the first * and maybe one space
|
|
18
|
+
line = stripped[1:]
|
|
19
|
+
if line.startswith(' '):
|
|
20
|
+
line = line[1:]
|
|
21
|
+
else:
|
|
22
|
+
# Maybe it didn't start with *, just use stripped or original?
|
|
23
|
+
# Standard JSDoc often has * aligned.
|
|
24
|
+
# If we stripped it, we lose indentation for code blocks.
|
|
25
|
+
# Let's try to be smart: remove optional whitespace then optional * then optional space.
|
|
26
|
+
# actually usually:
|
|
27
|
+
# /**
|
|
28
|
+
# * desc
|
|
29
|
+
# */
|
|
30
|
+
# so we just match ^\s*\*\s?
|
|
31
|
+
m = re.match(r'^\s*\*\s?(.*)', line)
|
|
32
|
+
if m:
|
|
33
|
+
line = m.group(1)
|
|
34
|
+
else:
|
|
35
|
+
line = line.strip()
|
|
36
|
+
cleaned_lines.append(line)
|
|
37
|
+
|
|
38
|
+
full_text = '\n'.join(cleaned_lines).strip()
|
|
39
|
+
|
|
40
|
+
# Now split by tags
|
|
41
|
+
# We look for @param, @return at start of lines (logically)
|
|
42
|
+
# But since we joined them, we can use regex or iterate lines again.
|
|
43
|
+
# Iterating lines is safer for context.
|
|
44
|
+
|
|
45
|
+
description_lines = []
|
|
46
|
+
params = []
|
|
47
|
+
returns = []
|
|
48
|
+
|
|
49
|
+
current_tag = None # None (desc), 'param', 'return'
|
|
50
|
+
current_buffer = []
|
|
51
|
+
|
|
52
|
+
# Helper to flush current buffer
|
|
53
|
+
def flush():
|
|
54
|
+
nonlocal current_tag, current_buffer
|
|
55
|
+
text = '\n'.join(current_buffer).strip()
|
|
56
|
+
if not text:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if current_tag is None:
|
|
60
|
+
description_lines.append(text)
|
|
61
|
+
elif current_tag == 'param':
|
|
62
|
+
# Parse param: @param {type} name description
|
|
63
|
+
# or @param name description
|
|
64
|
+
# text starts after @param
|
|
65
|
+
# We need to handle the content we've collected
|
|
66
|
+
|
|
67
|
+
# Regex for param:
|
|
68
|
+
# ^(\{.*?\})?\s*(\S+)\s*(.*)
|
|
69
|
+
# group 1: type (optional)
|
|
70
|
+
# group 2: name
|
|
71
|
+
# group 3: desc
|
|
72
|
+
m = re.match(r'^(?:\{([^\}]+)\})?\s*(\S+)\s*(.*)', text, re.DOTALL)
|
|
73
|
+
if m:
|
|
74
|
+
type_info = m.group(1) or ""
|
|
75
|
+
name = m.group(2)
|
|
76
|
+
desc = m.group(3)
|
|
77
|
+
params.append(DocParam(name=name, description=desc, type_info=type_info))
|
|
78
|
+
else:
|
|
79
|
+
# Fallback
|
|
80
|
+
params.append(DocParam(name="?", description=text))
|
|
81
|
+
|
|
82
|
+
elif current_tag == 'return':
|
|
83
|
+
# @return {type} name description
|
|
84
|
+
# or @return name description
|
|
85
|
+
# Try to match {type} name desc first
|
|
86
|
+
m = re.match(r'^(?:\{([^\}]+)\})?\s*(\S+)\s*(.*)', text, re.DOTALL)
|
|
87
|
+
if m:
|
|
88
|
+
type_info = m.group(1) or ""
|
|
89
|
+
name = m.group(2)
|
|
90
|
+
desc = m.group(3)
|
|
91
|
+
returns.append(DocReturn(name=name, description=desc, type_info=type_info))
|
|
92
|
+
else:
|
|
93
|
+
returns.append(DocReturn(description=text))
|
|
94
|
+
|
|
95
|
+
current_buffer = []
|
|
96
|
+
|
|
97
|
+
for line in cleaned_lines:
|
|
98
|
+
# Check for tag
|
|
99
|
+
m = re.match(r'^@(\w+)(?:\s+(.*))?$', line)
|
|
100
|
+
if m:
|
|
101
|
+
flush()
|
|
102
|
+
tag_name = m.group(1)
|
|
103
|
+
content_start = m.group(2) or ""
|
|
104
|
+
|
|
105
|
+
if tag_name == 'param':
|
|
106
|
+
current_tag = 'param'
|
|
107
|
+
current_buffer = [content_start]
|
|
108
|
+
elif tag_name in ('return', 'returns'):
|
|
109
|
+
current_tag = 'return'
|
|
110
|
+
current_buffer = [content_start]
|
|
111
|
+
else:
|
|
112
|
+
# Unknown tag, treat as part of description or ignore?
|
|
113
|
+
# JSDoc usually keeps them or hides them.
|
|
114
|
+
# For now let's append to description if it was checking description,
|
|
115
|
+
# or just ignore.
|
|
116
|
+
# Let's treat unknown tags as description for now?
|
|
117
|
+
# Or skipping. Let's skip unknown tags for now to keep clean.
|
|
118
|
+
current_tag = 'unknown'
|
|
119
|
+
current_buffer = [line]
|
|
120
|
+
else:
|
|
121
|
+
if current_tag != 'unknown':
|
|
122
|
+
current_buffer.append(line)
|
|
123
|
+
|
|
124
|
+
flush()
|
|
125
|
+
|
|
126
|
+
return '\n'.join(description_lines).strip(), params, returns
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csdoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Documentation generator for Csound projects
|
|
5
|
+
Project-URL: Homepage, https://github.com/kunstmusik/csdoc
|
|
6
|
+
Project-URL: Repository, https://github.com/kunstmusik/csdoc
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/kunstmusik/csdoc/issues
|
|
8
|
+
Author-email: Steven Yi <stevenyi@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: audio,csound,documentation,music
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Requires-Dist: jinja2>=3.1.6
|
|
17
|
+
Requires-Dist: markdown>=3.10.1
|
|
18
|
+
Requires-Dist: typer>=0.21.1
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# csdoc
|
|
22
|
+
|
|
23
|
+
`csdoc` is a documentation generator for Csound projects. It parses Csound code (`.csd`, `.orc`, `.inc`, etc.) and extracts JSDoc-style comment blocks to generate a beautiful, static HTML documentation site.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Standard Syntax**: Supports `opcode` (UDOs), `instr`, and `struct` definitions.
|
|
28
|
+
- **Recursive Parsing**: Automatically follows `#include` statements.
|
|
29
|
+
- **Rich Comments**: Supports `@param`, `@return`, and Markdown in descriptions.
|
|
30
|
+
- **Modern CLI**: Easy to use with `uv` or as a standalone tool.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
You can run `csdoc` directly using `uvx`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uvx --from git+https://github.com/yourusername/csdoc csdoc build main.csd
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or install it in your environment:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install csdoc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### Build Documentation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
csdoc build <source_file> [options]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `<source_file>`: The entry point of your Csound project (e.g., `main.csd`).
|
|
55
|
+
- `-o, --output <dir>`: The directory to save the generated site (default: `dist`).
|
|
56
|
+
|
|
57
|
+
### Export JSON
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
csdoc json <source_file>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Documentation Format
|
|
64
|
+
|
|
65
|
+
`csdoc` looks for tags within `/** ... */` comment blocks immediately preceding a definition.
|
|
66
|
+
|
|
67
|
+
### Example
|
|
68
|
+
|
|
69
|
+
```csound
|
|
70
|
+
/**
|
|
71
|
+
* A gain control UDO.
|
|
72
|
+
*
|
|
73
|
+
* This opcode applies a simple linear gain to an audio signal.
|
|
74
|
+
*
|
|
75
|
+
* @param ain The input audio signal
|
|
76
|
+
* @param kgain The gain multiplier (0.0 to 1.0)
|
|
77
|
+
* @return aout The processed audio signal
|
|
78
|
+
*/
|
|
79
|
+
opcode Gain, a, ak
|
|
80
|
+
ain, kgain xin
|
|
81
|
+
xout ain * kgain
|
|
82
|
+
endop
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Supported Tags
|
|
86
|
+
|
|
87
|
+
| Tag | Description |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `@param {type} name Description` | Documents a parameter or p-field. Type is optional. |
|
|
90
|
+
| `@return {type} Description` | Documents a return value. Type is optional. |
|
|
91
|
+
| `@returns` | Alias for `@return`. |
|
|
92
|
+
|
|
93
|
+
Descriptions support standard **Markdown** syntax, including code blocks, bold text, and lists.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
This project uses `uv` for dependency management.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Install dependencies
|
|
101
|
+
uv sync
|
|
102
|
+
|
|
103
|
+
# Run the CLI
|
|
104
|
+
uv run csdoc --help
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
csdoc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
csdoc/cli.py,sha256=Ks92ORHTEjWpHTRMG-eshFTxyI8s_omklNoQlUbyHhk,1795
|
|
3
|
+
csdoc/models.py,sha256=DWAomGgtm9xqE8C2uzvEpbXg84czzElXvw5WPO2DCFs,1380
|
|
4
|
+
csdoc/generator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
csdoc/generator/builder.py,sha256=8kGJACMw71Ldxn2NQqz4S4gHO86nEgvheDkOA2OkdJ4,2368
|
|
6
|
+
csdoc/generator/templates/base.html,sha256=SIhX7yqPe5ZzKUxHSa3pwCxGdIhrLAvLitlW_W0fsDg,3817
|
|
7
|
+
csdoc/generator/templates/index.html,sha256=gOns99jbRrOB0D6LQMAcDdBzIiLFgED5gVqSJnpu3Q8,4289
|
|
8
|
+
csdoc/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
csdoc/parser/csound.py,sha256=B9VK2mdBBIfLVeS_1ycvEJ20MLY9trXZwF1228NpaDY,8632
|
|
10
|
+
csdoc/parser/docblock.py,sha256=cSkvUtl4N9aQCpHiFHRLE_MKfssW7XFknAXZX7gp1_s,4577
|
|
11
|
+
csdoc-0.1.0.dist-info/METADATA,sha256=UKWsMxmUFY3TyoVVzncYtEzz5lOsGleug_8-asqA9h0,2771
|
|
12
|
+
csdoc-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
csdoc-0.1.0.dist-info/entry_points.txt,sha256=8HboyOzdeu4iHcb9WtWRZQS5YoMtGn1ZOUZUgxeQfLY,40
|
|
14
|
+
csdoc-0.1.0.dist-info/RECORD,,
|