1use crate::ir::{AnnotationKind, Attachment, Module, Node, NodeId, Value};
6use crate::types::{ActiveType, Unit};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
12pub struct DocItem {
13 pub path: String,
14 pub depth: usize,
15 pub section: String,
16 pub is_table: bool,
17 pub type_text: String,
18 pub required: bool,
19 pub default_text: Option<String>,
20 pub value_text: String,
21 pub description: String,
22 pub constraints: Vec<String>,
23 pub deprecated: Option<String>,
24}
25
26pub fn collect_items(module: &Module, value: &Value) -> Vec<DocItem> {
30 let mut by_path: HashMap<String, NodeId> = HashMap::new();
33 for (name, id) in &module.bindings {
34 by_path.insert(name.clone(), *id);
35 }
36 for (id, path) in module.value_paths() {
37 by_path.insert(path, id);
38 }
39 let mut comments: HashMap<String, Vec<String>> = HashMap::new();
40 for trivia in &module.trivia {
46 let key = match &trivia.attachment {
47 Attachment::Leading(key) | Attachment::Trailing(key) => key.clone(),
48 _ => continue,
49 };
50 let text = strip_comment(&trivia.text);
51 if text.starts_with('@') {
55 continue;
56 }
57 comments.entry(key).or_default().push(text);
58 }
59 let mut items = Vec::new();
60 if let Value::Table(map) = value {
61 for (key, val) in map {
62 walk_value(module, val, key, 0, &by_path, &comments, &mut items);
63 }
64 }
65 items
66}
67
68fn walk_value(
72 module: &Module,
73 val: &Value,
74 path: &str,
75 depth: usize,
76 by_path: &HashMap<String, NodeId>,
77 comments: &HashMap<String, Vec<String>>,
78 items: &mut Vec<DocItem>,
79) {
80 let section = if depth == 0 && !matches!(val, Value::Table(_) | Value::Array(_)) {
81 "General".to_string()
82 } else {
83 path.split('.')
84 .next()
85 .unwrap_or(path)
86 .split('[')
87 .next()
88 .unwrap_or(path)
89 .to_string()
90 };
91 let node = by_path.get(path).copied();
92 let schema = schema_node_at(module, path);
93 let is_table = matches!(val, Value::Table(_));
94 let (type_text, required, default_text, constraints, deprecated) =
95 describe_schema(module, schema, val);
96 let mut description_parts: Vec<String> = Vec::new();
97 if let Some(id) = node {
98 if let Some(anns) = module.annotations.get(&id) {
99 for ann in anns {
100 match &ann.kind {
101 AnnotationKind::Intent(text) => description_parts.push(text.clone()),
102 AnnotationKind::Constraint { source, .. } => {
103 description_parts.push(format!("Constraint: `{source}`."));
104 }
105 AnnotationKind::AiImmutable(true) => {
106 description_parts.push("Agent-immutable.".to_string());
107 }
108 AnnotationKind::AiImmutable(false) => {}
109 }
110 }
111 }
112 }
113 if let Some(lines) = comments.get(path) {
114 description_parts.extend(lines.iter().cloned());
115 }
116 description_parts.retain(|part| !part.trim().is_empty());
119 items.push(DocItem {
120 path: path.to_string(),
121 depth,
122 section,
123 is_table,
124 type_text,
125 required,
126 default_text,
127 value_text: render_value(val),
128 description: description_parts.join("\n\n"),
129 constraints,
130 deprecated,
131 });
132 match val {
133 Value::Table(nested) => {
134 for (key, child) in nested {
135 walk_value(
136 module,
137 child,
138 &format!("{path}.{key}"),
139 depth + 1,
140 by_path,
141 comments,
142 items,
143 );
144 }
145 }
146 Value::Array(elements) => {
147 for (i, element) in elements.iter().enumerate() {
148 walk_value(
149 module,
150 element,
151 &format!("{path}[{i}]"),
152 depth + 1,
153 by_path,
154 comments,
155 items,
156 );
157 }
158 }
159 _ => {}
160 }
161}
162
163fn describe_schema(
168 module: &Module,
169 schema: Option<NodeId>,
170 val: &Value,
171) -> (String, bool, Option<String>, Vec<String>, Option<String>) {
172 let fallback = (value_kind_name(val), true, None, Vec::new(), None);
173 let Some(id) = schema else {
174 return fallback;
175 };
176 match module.get(id) {
177 Some(Node::SchemaTable { closed, .. }) => (
178 "Table".to_string(),
179 true,
180 None,
181 if *closed {
182 vec!["closed table".to_string()]
183 } else {
184 Vec::new()
185 },
186 None,
187 ),
188 Some(Node::SchemaArray { element }) => {
189 let inner = match module.get(*element) {
190 Some(Node::SchemaField { field }) => field.typ.name(),
191 Some(Node::SchemaTable { .. }) => "Table".to_string(),
192 Some(Node::SchemaArray { .. }) => "Array".to_string(),
193 _ => "Any".to_string(),
194 };
195 (format!("Array<{inner}>"), true, None, Vec::new(), None)
196 }
197 Some(Node::SchemaField { field }) => {
198 let mut constraints = Vec::new();
199 match &field.typ {
200 ActiveType::Int => {
201 push_bound(module, field.min, "min", &mut constraints);
202 push_bound(module, field.max, "max", &mut constraints);
203 }
204 ActiveType::Float => {
205 push_bound(module, field.min, "min", &mut constraints);
206 push_bound(module, field.max, "max", &mut constraints);
207 }
208 ActiveType::String => {
209 push_bound(module, field.min_len, "min_len", &mut constraints);
210 push_bound(module, field.max_len, "max_len", &mut constraints);
211 if let Some(pattern) = static_string(module, field.pattern) {
212 constraints.push(format!("pattern `{pattern}`"));
213 }
214 }
215 _ => {}
216 }
217 let default_text = match field.default.and_then(|d| module.get(d)) {
218 Some(Node::Literal(value)) => Some(render_value(value)),
219 _ => None,
220 };
221 let required = !matches!(field.typ, ActiveType::Null) && default_text.is_none();
222 (
223 field.typ.name(),
224 required,
225 default_text,
226 constraints,
227 field.deprecated.clone(),
228 )
229 }
230 _ => fallback,
231 }
232}
233
234fn schema_node_at(module: &Module, path: &str) -> Option<NodeId> {
239 let mut id = module.schema?;
240 for segment in path.split('.') {
241 let (key, indexed) = match segment.split_once('[') {
242 Some((key, _)) => (key, true),
243 None => (segment, false),
244 };
245 match module.get(id) {
246 Some(Node::SchemaTable { fields, .. }) => {
247 id = *fields.get(key)?;
248 if indexed {
249 match module.get(id) {
250 Some(Node::SchemaArray { element }) => id = *element,
251 _ => return None,
252 }
253 }
254 }
255 Some(Node::SchemaArray { element }) => {
258 id = *element;
259 }
260 _ => return None,
261 }
262 }
263 Some(id)
264}
265
266fn push_bound(module: &Module, bound: Option<NodeId>, name: &str, constraints: &mut Vec<String>) {
267 let rendered = match bound.and_then(|id| module.get(id)) {
268 Some(Node::Literal(crate::ir::Value::Int(i))) => Some(i.to_string()),
269 Some(Node::Literal(crate::ir::Value::Float(f))) => Some(f.to_string()),
270 _ => None,
271 };
272 if let Some(text) = rendered {
273 constraints.push(format!("{name} {text}"));
274 }
275}
276
277fn static_string(module: &Module, id: Option<NodeId>) -> Option<String> {
278 match id.and_then(|node| module.get(node)) {
279 Some(Node::Literal(crate::ir::Value::String(s))) => Some(s.clone()),
280 _ => None,
281 }
282}
283
284fn value_kind_name(val: &Value) -> String {
285 match val {
286 Value::Null => "Null".to_string(),
287 Value::Bool(_) => "Bool".to_string(),
288 Value::Int(_) => "Int".to_string(),
289 Value::Float(_) => "Float".to_string(),
290 Value::String(_) => "String".to_string(),
291 Value::DurationMs(_) => "Duration".to_string(),
292 Value::Quantity(_, _) => "Quantity".to_string(),
293 Value::Array(_) => "Array".to_string(),
294 Value::Table(map) => format!("Table({})", map.len()),
295 Value::Secret(_) => "Secret".to_string(),
296 }
297}
298
299fn render_value(val: &Value) -> String {
302 match val {
303 Value::Secret(_) => "***SECRET***".to_string(),
304 Value::String(s) => s.clone(),
305 Value::Int(i) => i.to_string(),
306 Value::Float(f) => f.to_string(),
307 Value::Bool(b) => b.to_string(),
308 Value::Null => "null".to_string(),
309 Value::DurationMs(ms) => format!("{ms} ms"),
310 Value::Quantity(n, unit) => match unit {
311 Unit::Percent => format!("{}%", n * 100.0),
312 _ => format!("{n} {}", unit.suffix()),
313 },
314 Value::Array(items) => format!("array[{}]", items.len()),
315 Value::Table(map) => format!("table{{{}}}", map.len()),
316 }
317}
318
319fn strip_comment(text: &str) -> String {
320 let stripped = text.trim_start_matches('#');
321 stripped.strip_prefix(' ').unwrap_or(stripped).to_string()
322}
323
324pub fn doc_to_markdown(title: &str, items: &[DocItem]) -> String {
333 let mut out = format!("# {title}\n\n");
334 let mut section: Option<&str> = None;
335 for item in items {
336 if section != Some(item.section.as_str()) {
337 out.push_str(&format!("## {}\n\n", item.section));
338 section = Some(item.section.as_str());
339 }
340 out.push_str(&format!("### {}\n\n", item.path));
341 let mut line = format!("**Type:** {}", item.type_text);
342 line.push_str(&format!(
343 " · **Required:** {}",
344 if item.required { "yes" } else { "no" }
345 ));
346 if let Some(default) = &item.default_text {
347 line.push_str(&format!(" · **Default:** `{default}`"));
348 }
349 line.push_str(&format!(" · **Value:** `{}`", item.value_text));
350 out.push_str(&line);
351 out.push_str("\n\n");
352 if !item.description.is_empty() {
353 out.push_str(&item.description);
354 out.push_str("\n\n");
355 }
356 for constraint in &item.constraints {
357 out.push_str(&format!("- {constraint}\n"));
358 }
359 if !item.constraints.is_empty() {
360 out.push('\n');
361 }
362 if let Some(reason) = &item.deprecated {
363 if reason.is_empty() {
364 out.push_str("> Deprecated.\n\n");
365 } else {
366 out.push_str(&format!("> Deprecated: {reason}\n\n"));
367 }
368 }
369 }
370 out
371}
372
373fn escape_html(s: &str) -> String {
378 let mut out = String::with_capacity(s.len());
379 for c in s.chars() {
380 match c {
381 '&' => out.push_str("&"),
382 '<' => out.push_str("<"),
383 '>' => out.push_str(">"),
384 '"' => out.push_str("""),
385 _ => out.push(c),
386 }
387 }
388 out
389}
390
391pub fn doc_to_html(title: &str, items: &[DocItem]) -> String {
394 let mut out = String::new();
395 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
396 out.push_str(&format!("<title>{}</title>\n", escape_html(title)));
397 out.push_str(
398 "<style>body{font-family:sans-serif;max-width:60em;margin:2em auto;padding:0 1em}\
399 .field{border-top:1px active #ccc;padding:.5em 0}code{background:#f4f4f4;padding:0 .3em}\
400 #q{width:100%;font-size:1.1em;padding:.3em;margin:1em 0}</style>\n",
401 );
402 out.push_str("</head>\n<body>\n");
403 out.push_str(&format!("<h1>{}</h1>\n", escape_html(title)));
404 out.push_str(
405 "<input type=\"search\" id=\"q\" placeholder=\"Filter fields…\" oninput=\"\
406 const q=this.value.toLowerCase();\
407 document.querySelectorAll('.field').forEach(el=>{\
408 el.style.display=el.textContent.toLowerCase().includes(q)?'':'none'})\">\n",
409 );
410 let mut section: Option<&str> = None;
411 for item in items {
412 if section != Some(item.section.as_str()) {
413 out.push_str(&format!("<h2>{}</h2>\n", escape_html(&item.section)));
414 section = Some(item.section.as_str());
415 }
416 out.push_str("<div class=\"field\">\n");
417 out.push_str(&format!(
418 "<h3><code>{}</code></h3>\n",
419 escape_html(&item.path)
420 ));
421 out.push_str("<p>");
422 out.push_str(&format!(
423 "<code>{}</code> · {} · <code>{}</code>",
424 escape_html(&item.type_text),
425 if item.required {
426 "required"
427 } else {
428 "optional"
429 },
430 escape_html(&item.value_text),
431 ));
432 if let Some(default) = &item.default_text {
433 out.push_str(&format!(" · default <code>{}</code>", escape_html(default)));
434 }
435 out.push_str("</p>\n");
436 for para in item.description.split("\n\n") {
437 if !para.is_empty() {
438 out.push_str(&format!("<p>{}</p>\n", escape_html(para)));
439 }
440 }
441 if !item.constraints.is_empty() {
442 out.push_str("<ul>\n");
443 for constraint in &item.constraints {
444 out.push_str(&format!(
445 "<li><code>{}</code></li>\n",
446 escape_html(constraint)
447 ));
448 }
449 out.push_str("</ul>\n");
450 }
451 if let Some(reason) = &item.deprecated {
452 if reason.is_empty() {
453 out.push_str("<p><strong>Deprecated.</strong></p>\n");
454 } else {
455 out.push_str(&format!(
456 "<p><strong>Deprecated:</strong> {}</p>\n",
457 escape_html(reason)
458 ));
459 }
460 }
461 out.push_str("</div>\n");
462 }
463 out.push_str("</body>\n</html>\n");
464 out
465}
466
467fn escape_man_line(line: &str) -> String {
475 let mut out = line.replace('\\', "\\\\");
476 if out.starts_with('\'') || out.starts_with('.') {
477 out.insert_str(0, "\\&");
478 }
479 out
480}
481
482pub fn doc_to_man(title: &str, items: &[DocItem]) -> String {
485 let mut out = format!(".TH \"{}\" 5\n", title.to_uppercase().replace('"', ""));
486 out.push_str(".SH NAME\n");
487 out.push_str(&escape_man_line(&format!(
488 "{title} \\- configuration reference"
489 )));
490 out.push('\n');
491 out.push_str(".SH FIELDS\n");
492 for item in items {
493 out.push_str(".TP\n");
494 out.push_str(&escape_man_line(&item.path));
495 out.push('\n');
496 let mut body = format!(
497 "Type: {}. Required: {}. Value: {}.",
498 item.type_text,
499 if item.required { "yes" } else { "no" },
500 item.value_text
501 );
502 if let Some(default) = &item.default_text {
503 body.push_str(&format!(" Default: {default}."));
504 }
505 if !item.constraints.is_empty() {
506 body.push_str(&format!(" Constraints: {}.", item.constraints.join("; ")));
507 }
508 if let Some(reason) = &item.deprecated {
509 if reason.is_empty() {
510 body.push_str(" Deprecated.");
511 } else {
512 body.push_str(&format!(" Deprecated: {reason}."));
513 }
514 }
515 for line in body.split('\n') {
516 out.push_str(&escape_man_line(line));
517 out.push('\n');
518 }
519 for para in item.description.split("\n\n") {
520 if para.is_empty() {
521 continue;
522 }
523 for line in para.split('\n') {
524 out.push_str(&escape_man_line(line));
525 out.push('\n');
526 }
527 out.push_str(".br\n");
528 }
529 }
530 out
531}