1use crate::ast::{TypeExpr, TypedParam};
28use harn_lexer::Span;
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
39#[serde(rename_all = "snake_case")]
40pub struct StdlibMetadata {
41 pub effects: Option<Vec<String>>,
44 pub errors: Option<Vec<String>>,
46 pub api_stability: Option<String>,
49 pub example: Option<String>,
52}
53
54impl StdlibMetadata {
55 pub fn is_complete(&self) -> bool {
57 self.effects.is_some() && self.errors.is_some()
58 }
59
60 pub fn is_empty(&self) -> bool {
63 self.effects.is_none()
64 && self.errors.is_none()
65 && self.api_stability.is_none()
66 && self.example.is_none()
67 }
68
69 pub fn missing_fields(&self) -> Vec<&'static str> {
71 let mut out: Vec<&'static str> = Vec::new();
72 if self.effects.is_none() {
73 out.push("effects");
74 }
75 if self.errors.is_none() {
76 out.push("errors");
77 }
78 out
79 }
80
81 pub fn to_markdown(&self) -> String {
85 self.to_markdown_with_derived_example(None)
86 }
87
88 pub fn to_markdown_with_derived_example(&self, derived: Option<&str>) -> String {
92 if self.is_empty() && derived.is_none() {
93 return String::new();
94 }
95 let mut lines: Vec<String> = Vec::new();
96 if let Some(effects) = &self.effects {
97 lines.push(format!(
98 "- **effects:** {}",
99 if effects.is_empty() {
100 "_none_".to_string()
101 } else {
102 effects
103 .iter()
104 .map(|e| format!("`{e}`"))
105 .collect::<Vec<_>>()
106 .join(", ")
107 }
108 ));
109 }
110 if let Some(errors) = &self.errors {
111 lines.push(format!(
112 "- **errors:** {}",
113 if errors.is_empty() {
114 "_none_".to_string()
115 } else {
116 errors
117 .iter()
118 .map(|e| format!("`{e}`"))
119 .collect::<Vec<_>>()
120 .join(", ")
121 }
122 ));
123 }
124 if let Some(stability) = &self.api_stability {
125 lines.push(format!("- **api_stability:** `{stability}`"));
126 }
127 if let Some(example) = &self.example {
128 lines.push(format!("- **example:**\n\n```harn\n{example}\n```"));
129 } else if let Some(derived) = derived {
130 lines.push(format!(
131 "- **example** _(derived from signature)_**:**\n\n```harn\n{derived}\n```"
132 ));
133 }
134 format!("**Stdlib metadata**\n\n{}", lines.join("\n"))
135 }
136}
137
138pub fn synthesize_example(
142 name: &str,
143 params: &[TypedParam],
144 return_type: Option<&TypeExpr>,
145) -> String {
146 let args = params
147 .iter()
148 .map(|p| {
149 if p.rest {
150 format!("...{}", p.name)
151 } else {
152 p.name.clone()
153 }
154 })
155 .collect::<Vec<_>>()
156 .join(", ");
157 let call = format!("{name}({args})");
158 match return_type {
159 Some(TypeExpr::Named(n)) if n == "nil" => call,
162 Some(_) => format!("const out = {call}"),
163 None => call,
164 }
165}
166
167pub fn parse_from_doc_body(body: &str) -> StdlibMetadata {
173 parse_from_doc_lines(&body.lines().collect::<Vec<_>>())
174}
175
176fn parse_from_doc_lines(lines: &[&str]) -> StdlibMetadata {
177 let mut meta = StdlibMetadata::default();
178 let mut current_key: Option<&'static str> = None;
179 let mut current_value: String = String::new();
180
181 let flush = |key: Option<&'static str>, value: String, meta: &mut StdlibMetadata| {
182 let Some(key) = key else { return };
183 let trimmed = value.trim_end_matches('\n').to_string();
184 assign_field(meta, key, &trimmed);
185 };
186
187 for raw in lines {
188 let line = raw.trim();
189 if let Some((key, rest)) = parse_key_line(line) {
190 flush(current_key, std::mem::take(&mut current_value), &mut meta);
192 current_key = Some(key);
193 current_value.clear();
194 current_value.push_str(rest.trim());
195 } else if current_key.is_some() {
196 if line.is_empty() {
200 flush(current_key, std::mem::take(&mut current_value), &mut meta);
201 current_key = None;
202 } else if current_key == Some("example") {
203 current_value.push('\n');
204 current_value.push_str(line);
205 }
206 }
207 }
208 flush(current_key, current_value, &mut meta);
209 meta
210}
211
212fn parse_key_line(line: &str) -> Option<(&'static str, &str)> {
213 let rest = line.strip_prefix('@')?;
214 let (key, after) = rest.split_once(':')?;
215 let key = match key.trim() {
216 "effects" => "effects",
217 "errors" => "errors",
218 "api_stability" => "api_stability",
219 "example" => "example",
220 _ => return None,
221 };
222 Some((key, after))
223}
224
225fn assign_field(meta: &mut StdlibMetadata, key: &str, value: &str) {
226 match key {
227 "effects" => meta.effects = Some(parse_list(value)),
228 "errors" => meta.errors = Some(parse_list(value)),
229 "api_stability" => meta.api_stability = Some(value.trim().to_string()),
230 "example" => meta.example = Some(value.trim().to_string()),
231 _ => {}
232 }
233}
234
235fn parse_list(raw: &str) -> Vec<String> {
236 let trimmed = raw.trim();
237 let stripped = trimmed
238 .strip_prefix('[')
239 .and_then(|s| s.strip_suffix(']'))
240 .unwrap_or(trimmed);
241 stripped
242 .split(',')
243 .map(|part| part.trim().to_string())
244 .filter(|part| !part.is_empty())
245 .collect()
246}
247
248pub fn parse_for_span(source: &str, span: &Span) -> Option<StdlibMetadata> {
253 let body = extract_doc_body(source, span)?;
254 Some(parse_from_doc_body(&body))
255}
256
257fn extract_doc_body(source: &str, span: &Span) -> Option<String> {
258 let lines: Vec<&str> = source.lines().collect();
259 let def_line_idx = span.line.checked_sub(1)?;
260 if def_line_idx == 0 {
261 return None;
262 }
263 let above_idx = def_line_idx - 1;
264 let above = lines.get(above_idx)?.trim_end();
265 if !above.trim_end().ends_with("*/") {
266 return None;
267 }
268
269 let above_trim = above.trim_start();
271 if let Some(inner) = above_trim
272 .strip_prefix("/**")
273 .and_then(|s| s.strip_suffix("*/"))
274 {
275 return Some(inner.trim().to_string());
276 }
277
278 let mut start_idx = above_idx;
280 loop {
281 let line = lines.get(start_idx)?.trim_start();
282 if line.starts_with("/**") {
283 break;
284 }
285 if start_idx == 0 {
286 return None;
287 }
288 start_idx -= 1;
289 }
290 let mut body = String::new();
291 for (i, line) in lines.iter().enumerate().take(above_idx + 1).skip(start_idx) {
292 let trimmed = line.trim();
293 let stripped = if i == start_idx {
294 trimmed.strip_prefix("/**").unwrap_or(trimmed).trim_start()
295 } else if i == above_idx {
296 let without_tail = trimmed.strip_suffix("*/").unwrap_or(trimmed).trim_end();
297 without_tail
298 .strip_prefix('*')
299 .map(|s| s.strip_prefix(' ').unwrap_or(s))
300 .unwrap_or(without_tail)
301 } else {
302 trimmed
303 .strip_prefix('*')
304 .map(|s| s.strip_prefix(' ').unwrap_or(s))
305 .unwrap_or(trimmed)
306 };
307 if !body.is_empty() {
308 body.push('\n');
309 }
310 body.push_str(stripped);
311 }
312 Some(body)
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn parses_all_fields_inline() {
321 let body = "Reads a file.\n\n@effects: [fs.read]\n@errors: [FileNotFound, PermissionDenied]\n@api_stability: experimental\n@example: let s = fs::read_to_string(harness.fs, \"/x\")";
322 let meta = parse_from_doc_body(body);
323 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
324 assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
325 assert_eq!(
326 meta.errors.as_deref(),
327 Some(&["FileNotFound".to_string(), "PermissionDenied".to_string()][..]),
328 );
329 assert_eq!(meta.api_stability.as_deref(), Some("experimental"));
330 assert_eq!(
331 meta.example.as_deref(),
332 Some("let s = fs::read_to_string(harness.fs, \"/x\")"),
333 );
334 }
335
336 #[test]
337 fn required_set_is_effects_and_errors_only() {
338 let body = "@effects: []\n@errors: []";
339 let meta = parse_from_doc_body(body);
340 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
341 assert!(meta.api_stability.is_none());
342 assert!(meta.example.is_none());
343 }
344
345 #[test]
346 fn partial_metadata_lists_missing_fields() {
347 let body = "@api_stability: experimental";
348 let meta = parse_from_doc_body(body);
349 assert!(!meta.is_complete());
350 assert!(!meta.is_empty());
351 assert_eq!(meta.missing_fields(), vec!["effects", "errors"]);
352 }
353
354 #[test]
355 fn empty_effect_and_error_lists_are_explicit() {
356 let body = "@effects: []\n@errors: []";
357 let meta = parse_from_doc_body(body);
358 assert_eq!(meta.effects.as_deref(), Some(&[][..]));
359 assert_eq!(meta.errors.as_deref(), Some(&[][..]));
360 }
361
362 #[test]
363 fn unknown_keys_do_not_pollute_storage() {
364 let body = "@deprecated: yes\n@allocation: stack-only\n@errors: []";
367 let meta = parse_from_doc_body(body);
368 assert_eq!(meta.errors.as_deref(), Some(&[][..]));
369 assert!(meta.effects.is_none());
370 }
371
372 #[test]
373 fn example_continuation_lines_are_joined() {
374 let body = "@example: let s = fs::open(p)\n let b = fs::read(s)\n fs::close(s)";
375 let meta = parse_from_doc_body(body);
376 assert_eq!(
377 meta.example.as_deref(),
378 Some("let s = fs::open(p)\nlet b = fs::read(s)\nfs::close(s)"),
379 );
380 }
381
382 #[test]
383 fn parse_for_span_extracts_multi_line_block() {
384 let source = "\
385/**
386 * Read the file.
387 *
388 * @effects: [fs.read]
389 * @errors: [FileNotFound]
390 */
391pub fn read_file(path) {
392 __fs_read_to_string(path)
393}
394";
395 let span = Span::with_offsets(0, 0, 7, 1);
396 let meta = parse_for_span(source, &span).expect("metadata present");
397 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
398 }
399
400 #[test]
401 fn parse_for_span_handles_single_line_block() {
402 let source = "/** @effects: [] @errors: [] @example: noop() */\npub fn noop() { }\n";
403 let span = Span::with_offsets(0, 0, 2, 1);
404 let meta = parse_for_span(source, &span).expect("metadata present");
405 assert!(!meta.is_empty());
407 }
408
409 #[test]
410 fn markdown_omits_unset_fields() {
411 let meta = StdlibMetadata {
412 effects: Some(vec!["fs.read".to_string()]),
413 errors: None,
414 api_stability: Some("experimental".to_string()),
415 example: None,
416 };
417 let md = meta.to_markdown();
418 assert!(md.contains("**effects:**"));
419 assert!(md.contains("**api_stability:**"));
420 assert!(!md.contains("**errors:**"));
421 assert!(!md.contains("**example"));
422 }
423
424 #[test]
425 fn markdown_prefers_authored_example_over_derived() {
426 let meta = StdlibMetadata {
427 effects: Some(vec![]),
428 errors: Some(vec![]),
429 api_stability: None,
430 example: Some("read_file(\"/etc/hosts\")".to_string()),
431 };
432 let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
433 assert!(md.contains("read_file(\"/etc/hosts\")"));
434 assert!(!md.contains("derived from signature"));
435 }
436
437 #[test]
438 fn markdown_falls_back_to_derived_example() {
439 let meta = StdlibMetadata {
440 effects: Some(vec![]),
441 errors: Some(vec![]),
442 api_stability: None,
443 example: None,
444 };
445 let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
446 assert!(md.contains("derived from signature"));
447 assert!(md.contains("let out = read_file(path)"));
448 }
449
450 #[test]
451 fn derived_example_renders_even_without_declared_fields() {
452 let meta = StdlibMetadata::default();
453 assert!(meta.to_markdown().is_empty());
454 let md = meta.to_markdown_with_derived_example(Some("greet(name)"));
455 assert!(md.contains("greet(name)"));
456 }
457
458 #[test]
459 fn synthesize_example_binds_non_nil_returns() {
460 use crate::ast::{TypeExpr, TypedParam};
461 let params = vec![TypedParam::untyped("path"), TypedParam::untyped("limit")];
462 let ret = TypeExpr::Named("string".to_string());
463 assert_eq!(
464 synthesize_example("read_file", ¶ms, Some(&ret)),
465 "const out = read_file(path, limit)",
466 );
467 }
468
469 #[test]
470 fn synthesize_example_skips_binding_for_nil_and_untyped_returns() {
471 use crate::ast::{TypeExpr, TypedParam};
472 let params = vec![TypedParam::untyped("event")];
473 let nil = TypeExpr::Named("nil".to_string());
474 assert_eq!(
475 synthesize_example("notify", ¶ms, Some(&nil)),
476 "notify(event)",
477 );
478 assert_eq!(synthesize_example("notify", ¶ms, None), "notify(event)");
479 }
480
481 #[test]
482 fn synthesize_example_spreads_rest_params() {
483 use crate::ast::TypedParam;
484 let mut rest = TypedParam::untyped("parts");
485 rest.rest = true;
486 assert_eq!(synthesize_example("join", &[rest], None), "join(...parts)");
487 }
488}