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 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)
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
+
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csdoc = csdoc.cli:app