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!("let 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 colon = rest.find(':')?;
215 let (key, after) = rest.split_at(colon);
216 let key = match key.trim() {
217 "effects" => "effects",
218 "errors" => "errors",
219 "api_stability" => "api_stability",
220 "example" => "example",
221 _ => return None,
222 };
223 Some((key, &after[1..]))
224}
225
226fn assign_field(meta: &mut StdlibMetadata, key: &str, value: &str) {
227 match key {
228 "effects" => meta.effects = Some(parse_list(value)),
229 "errors" => meta.errors = Some(parse_list(value)),
230 "api_stability" => meta.api_stability = Some(value.trim().to_string()),
231 "example" => meta.example = Some(value.trim().to_string()),
232 _ => {}
233 }
234}
235
236fn parse_list(raw: &str) -> Vec<String> {
237 let trimmed = raw.trim();
238 let stripped = trimmed
239 .strip_prefix('[')
240 .and_then(|s| s.strip_suffix(']'))
241 .unwrap_or(trimmed);
242 stripped
243 .split(',')
244 .map(|part| part.trim().to_string())
245 .filter(|part| !part.is_empty())
246 .collect()
247}
248
249pub fn parse_for_span(source: &str, span: &Span) -> Option<StdlibMetadata> {
254 let body = extract_doc_body(source, span)?;
255 Some(parse_from_doc_body(&body))
256}
257
258fn extract_doc_body(source: &str, span: &Span) -> Option<String> {
259 let lines: Vec<&str> = source.lines().collect();
260 let def_line_idx = span.line.checked_sub(1)?;
261 if def_line_idx == 0 {
262 return None;
263 }
264 let above_idx = def_line_idx - 1;
265 let above = lines.get(above_idx)?.trim_end();
266 if !above.trim_end().ends_with("*/") {
267 return None;
268 }
269
270 let above_trim = above.trim_start();
272 if above_trim.starts_with("/**") && above_trim.ends_with("*/") && above_trim.len() >= 5 {
273 let inner = &above_trim[3..above_trim.len() - 2];
274 return Some(inner.trim().to_string());
275 }
276
277 let mut start_idx = above_idx;
279 loop {
280 let line = lines.get(start_idx)?.trim_start();
281 if line.starts_with("/**") {
282 break;
283 }
284 if start_idx == 0 {
285 return None;
286 }
287 start_idx -= 1;
288 }
289 let mut body = String::new();
290 for (i, line) in lines.iter().enumerate().take(above_idx + 1).skip(start_idx) {
291 let trimmed = line.trim();
292 let stripped = if i == start_idx {
293 trimmed.strip_prefix("/**").unwrap_or(trimmed).trim_start()
294 } else if i == above_idx {
295 let without_tail = trimmed.strip_suffix("*/").unwrap_or(trimmed).trim_end();
296 without_tail
297 .strip_prefix('*')
298 .map(|s| s.strip_prefix(' ').unwrap_or(s))
299 .unwrap_or(without_tail)
300 } else {
301 trimmed
302 .strip_prefix('*')
303 .map(|s| s.strip_prefix(' ').unwrap_or(s))
304 .unwrap_or(trimmed)
305 };
306 if !body.is_empty() {
307 body.push('\n');
308 }
309 body.push_str(stripped);
310 }
311 Some(body)
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn parses_all_fields_inline() {
320 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\")";
321 let meta = parse_from_doc_body(body);
322 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
323 assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
324 assert_eq!(
325 meta.errors.as_deref(),
326 Some(&["FileNotFound".to_string(), "PermissionDenied".to_string()][..]),
327 );
328 assert_eq!(meta.api_stability.as_deref(), Some("experimental"));
329 assert_eq!(
330 meta.example.as_deref(),
331 Some("let s = fs::read_to_string(harness.fs, \"/x\")"),
332 );
333 }
334
335 #[test]
336 fn required_set_is_effects_and_errors_only() {
337 let body = "@effects: []\n@errors: []";
338 let meta = parse_from_doc_body(body);
339 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
340 assert!(meta.api_stability.is_none());
341 assert!(meta.example.is_none());
342 }
343
344 #[test]
345 fn partial_metadata_lists_missing_fields() {
346 let body = "@api_stability: experimental";
347 let meta = parse_from_doc_body(body);
348 assert!(!meta.is_complete());
349 assert!(!meta.is_empty());
350 assert_eq!(meta.missing_fields(), vec!["effects", "errors"]);
351 }
352
353 #[test]
354 fn empty_effect_and_error_lists_are_explicit() {
355 let body = "@effects: []\n@errors: []";
356 let meta = parse_from_doc_body(body);
357 assert_eq!(meta.effects.as_deref(), Some(&[][..]));
358 assert_eq!(meta.errors.as_deref(), Some(&[][..]));
359 }
360
361 #[test]
362 fn unknown_keys_do_not_pollute_storage() {
363 let body = "@deprecated: yes\n@allocation: stack-only\n@errors: []";
366 let meta = parse_from_doc_body(body);
367 assert_eq!(meta.errors.as_deref(), Some(&[][..]));
368 assert!(meta.effects.is_none());
369 }
370
371 #[test]
372 fn example_continuation_lines_are_joined() {
373 let body = "@example: let s = fs::open(p)\n let b = fs::read(s)\n fs::close(s)";
374 let meta = parse_from_doc_body(body);
375 assert_eq!(
376 meta.example.as_deref(),
377 Some("let s = fs::open(p)\nlet b = fs::read(s)\nfs::close(s)"),
378 );
379 }
380
381 #[test]
382 fn parse_for_span_extracts_multi_line_block() {
383 let source = "\
384/**
385 * Read the file.
386 *
387 * @effects: [fs.read]
388 * @errors: [FileNotFound]
389 */
390pub fn read_file(path) {
391 __fs_read_to_string(path)
392}
393";
394 let span = Span::with_offsets(0, 0, 7, 1);
395 let meta = parse_for_span(source, &span).expect("metadata present");
396 assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
397 }
398
399 #[test]
400 fn parse_for_span_handles_single_line_block() {
401 let source = "/** @effects: [] @errors: [] @example: noop() */\npub fn noop() { }\n";
402 let span = Span::with_offsets(0, 0, 2, 1);
403 let meta = parse_for_span(source, &span).expect("metadata present");
404 assert!(!meta.is_empty());
406 }
407
408 #[test]
409 fn markdown_omits_unset_fields() {
410 let meta = StdlibMetadata {
411 effects: Some(vec!["fs.read".to_string()]),
412 errors: None,
413 api_stability: Some("experimental".to_string()),
414 example: None,
415 };
416 let md = meta.to_markdown();
417 assert!(md.contains("**effects:**"));
418 assert!(md.contains("**api_stability:**"));
419 assert!(!md.contains("**errors:**"));
420 assert!(!md.contains("**example"));
421 }
422
423 #[test]
424 fn markdown_prefers_authored_example_over_derived() {
425 let meta = StdlibMetadata {
426 effects: Some(vec![]),
427 errors: Some(vec![]),
428 api_stability: None,
429 example: Some("read_file(\"/etc/hosts\")".to_string()),
430 };
431 let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
432 assert!(md.contains("read_file(\"/etc/hosts\")"));
433 assert!(!md.contains("derived from signature"));
434 }
435
436 #[test]
437 fn markdown_falls_back_to_derived_example() {
438 let meta = StdlibMetadata {
439 effects: Some(vec![]),
440 errors: Some(vec![]),
441 api_stability: None,
442 example: None,
443 };
444 let md = meta.to_markdown_with_derived_example(Some("let out = read_file(path)"));
445 assert!(md.contains("derived from signature"));
446 assert!(md.contains("let out = read_file(path)"));
447 }
448
449 #[test]
450 fn derived_example_renders_even_without_declared_fields() {
451 let meta = StdlibMetadata::default();
452 assert!(meta.to_markdown().is_empty());
453 let md = meta.to_markdown_with_derived_example(Some("greet(name)"));
454 assert!(md.contains("greet(name)"));
455 }
456
457 #[test]
458 fn synthesize_example_binds_non_nil_returns() {
459 use crate::ast::{TypeExpr, TypedParam};
460 let params = vec![TypedParam::untyped("path"), TypedParam::untyped("limit")];
461 let ret = TypeExpr::Named("string".to_string());
462 assert_eq!(
463 synthesize_example("read_file", ¶ms, Some(&ret)),
464 "let out = read_file(path, limit)",
465 );
466 }
467
468 #[test]
469 fn synthesize_example_skips_binding_for_nil_and_untyped_returns() {
470 use crate::ast::{TypeExpr, TypedParam};
471 let params = vec![TypedParam::untyped("event")];
472 let nil = TypeExpr::Named("nil".to_string());
473 assert_eq!(
474 synthesize_example("notify", ¶ms, Some(&nil)),
475 "notify(event)",
476 );
477 assert_eq!(synthesize_example("notify", ¶ms, None), "notify(event)");
478 }
479
480 #[test]
481 fn synthesize_example_spreads_rest_params() {
482 use crate::ast::TypedParam;
483 let mut rest = TypedParam::untyped("parts");
484 rest.rest = true;
485 assert_eq!(synthesize_example("join", &[rest], None), "join(...parts)");
486 }
487}