| 1 | module document |
| 2 | |
| 3 | import os |
| 4 | import time |
| 5 | import v.ast |
| 6 | import v.checker |
| 7 | import v.fmt |
| 8 | import v.parser |
| 9 | import v.pref |
| 10 | import v.scanner |
| 11 | import v.token |
| 12 | |
| 13 | // SymbolKind categorizes the symbols it documents. |
| 14 | // The names are intentionally not in order as a guide when sorting the nodes. |
| 15 | pub enum SymbolKind { |
| 16 | none |
| 17 | const_group |
| 18 | constant |
| 19 | variable |
| 20 | function |
| 21 | method |
| 22 | interface |
| 23 | typedef |
| 24 | enum |
| 25 | enum_field |
| 26 | struct |
| 27 | struct_field |
| 28 | } |
| 29 | |
| 30 | pub enum Platform { |
| 31 | auto |
| 32 | ios |
| 33 | macos |
| 34 | linux |
| 35 | windows |
| 36 | freebsd |
| 37 | openbsd |
| 38 | netbsd |
| 39 | dragonfly |
| 40 | js // for interoperability in prefs.OS |
| 41 | android |
| 42 | termux // like android, but note that termux is running on devices natively, not cross compiling from other platforms |
| 43 | solaris |
| 44 | serenity |
| 45 | plan9 |
| 46 | vinix |
| 47 | haiku |
| 48 | raw |
| 49 | cross // TODO: add functionality for v doc -cross whenever possible |
| 50 | } |
| 51 | |
| 52 | // copy of pref.os_from_string |
| 53 | pub fn platform_from_string(platform_str string) !Platform { |
| 54 | match platform_str { |
| 55 | 'all', 'cross' { return .cross } |
| 56 | 'linux' { return .linux } |
| 57 | 'windows' { return .windows } |
| 58 | 'ios' { return .ios } |
| 59 | 'macos' { return .macos } |
| 60 | 'freebsd' { return .freebsd } |
| 61 | 'openbsd' { return .openbsd } |
| 62 | 'netbsd' { return .netbsd } |
| 63 | 'dragonfly' { return .dragonfly } |
| 64 | 'js' { return .js } |
| 65 | 'solaris' { return .solaris } |
| 66 | 'serenity' { return .serenity } |
| 67 | 'plan9' { return .plan9 } |
| 68 | 'vinix' { return .vinix } |
| 69 | 'android' { return .android } |
| 70 | 'termux' { return .termux } |
| 71 | 'haiku' { return .haiku } |
| 72 | 'nix' { return .linux } |
| 73 | '' { return .auto } |
| 74 | else { return error('vdoc: invalid platform `${platform_str}`') } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | pub fn platform_from_filename(filename string) Platform { |
| 79 | suffix := filename.all_after_last('_').all_before('.c.v') |
| 80 | mut platform := platform_from_string(suffix) or { Platform.cross } |
| 81 | if platform == .auto { |
| 82 | platform = .cross |
| 83 | } |
| 84 | return platform |
| 85 | } |
| 86 | |
| 87 | pub fn (sk SymbolKind) str() string { |
| 88 | return match sk { |
| 89 | .const_group { 'Constants' } |
| 90 | .function, .method { 'fn' } |
| 91 | .interface { 'interface' } |
| 92 | .typedef { 'type' } |
| 93 | .enum { 'enum' } |
| 94 | .struct { 'struct' } |
| 95 | else { '' } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | @[minify] |
| 100 | pub struct Doc { |
| 101 | pub mut: |
| 102 | prefs &pref.Preferences = new_vdoc_preferences() |
| 103 | base_path string |
| 104 | table &ast.Table = ast.new_table() |
| 105 | checker checker.Checker = checker.Checker{ |
| 106 | table: unsafe { nil } |
| 107 | pref: unsafe { nil } |
| 108 | } |
| 109 | fmt fmt.Fmt |
| 110 | filename string |
| 111 | pos int |
| 112 | pub_only bool = true |
| 113 | with_comments bool = true |
| 114 | with_pos bool |
| 115 | with_head bool = true |
| 116 | is_vlib bool |
| 117 | time_generated time.Time |
| 118 | head DocNode |
| 119 | contents map[string]DocNode |
| 120 | scoped_contents map[string]DocNode |
| 121 | parent_mod_name string |
| 122 | orig_mod_name string |
| 123 | extract_vars bool |
| 124 | filter_symbol_names []string |
| 125 | common_symbols []string |
| 126 | platform Platform |
| 127 | } |
| 128 | |
| 129 | @[minify] |
| 130 | pub struct DocNode { |
| 131 | pub mut: |
| 132 | name string |
| 133 | content string |
| 134 | comments []DocComment |
| 135 | pos token.Pos |
| 136 | file_path string |
| 137 | kind SymbolKind |
| 138 | tags []string |
| 139 | parent_name string |
| 140 | return_type string |
| 141 | children []DocNode |
| 142 | attrs map[string]string @[json: attributes] |
| 143 | from_scope bool |
| 144 | is_pub bool @[json: public] |
| 145 | platform Platform |
| 146 | is_readme bool |
| 147 | frontmatter map[string]string |
| 148 | } |
| 149 | |
| 150 | // new_vdoc_preferences creates a new instance of pref.Preferences tailored for v.doc. |
| 151 | pub fn new_vdoc_preferences() &pref.Preferences { |
| 152 | // vdoc should be able to parse as much user code as possible |
| 153 | // so its preferences should be permissive: |
| 154 | mut pref_ := &pref.Preferences{ |
| 155 | enable_globals: true |
| 156 | is_fmt: true |
| 157 | is_vdoc: true |
| 158 | } |
| 159 | pref_.fill_with_defaults() |
| 160 | return pref_ |
| 161 | } |
| 162 | |
| 163 | // new creates a new instance of a `Doc` struct. |
| 164 | pub fn new(input_path string) Doc { |
| 165 | mut d := Doc{ |
| 166 | base_path: os.real_path(input_path) |
| 167 | table: ast.new_table() |
| 168 | head: DocNode{} |
| 169 | contents: map[string]DocNode{} |
| 170 | time_generated: time.now() |
| 171 | } |
| 172 | d.fmt = fmt.Fmt{ |
| 173 | pref: d.prefs |
| 174 | indent: 0 |
| 175 | is_debug: false |
| 176 | table: d.table |
| 177 | } |
| 178 | d.checker = checker.new_checker(d.table, d.prefs) |
| 179 | return d |
| 180 | } |
| 181 | |
| 182 | // stmt reads the data of an `ast.Stmt` node and returns a `DocNode`. |
| 183 | // An option error is thrown if the symbol is not exposed to the public |
| 184 | // (when `pub_only` is enabled) or the content's of the AST node is empty. |
| 185 | pub fn (mut d Doc) stmt(mut stmt ast.Stmt, filename string) !DocNode { |
| 186 | mut name := d.stmt_name(stmt) |
| 187 | if name in d.common_symbols { |
| 188 | return error('already documented') |
| 189 | } |
| 190 | if name.starts_with(d.orig_mod_name + '.') { |
| 191 | name = name.all_after(d.orig_mod_name + '.') |
| 192 | } |
| 193 | mut node := DocNode{ |
| 194 | name: name |
| 195 | content: d.stmt_signature(stmt) |
| 196 | pos: stmt.pos |
| 197 | file_path: os.join_path(d.base_path, filename) |
| 198 | is_pub: d.stmt_pub(stmt) |
| 199 | platform: platform_from_filename(filename) |
| 200 | } |
| 201 | if (!node.is_pub && d.pub_only) || stmt is ast.GlobalDecl { |
| 202 | return error('symbol ${node.name} not public') |
| 203 | } |
| 204 | if node.name.starts_with(d.orig_mod_name + '.') { |
| 205 | node.name = node.name.all_after(d.orig_mod_name + '.') |
| 206 | } |
| 207 | if node.name == '' && node.comments.len == 0 && node.content.len == 0 { |
| 208 | return error('empty stmt') |
| 209 | } |
| 210 | match mut stmt { |
| 211 | ast.ConstDecl { |
| 212 | node.kind = .const_group |
| 213 | node.parent_name = 'Constants' |
| 214 | if d.extract_vars { |
| 215 | for mut field in stmt.fields { |
| 216 | ret_type := if field.typ == 0 { |
| 217 | d.expr_typ_to_string(mut field.expr) |
| 218 | } else { |
| 219 | d.type_to_str(field.typ) |
| 220 | } |
| 221 | node.children << DocNode{ |
| 222 | name: field.name.all_after(d.orig_mod_name + '.') |
| 223 | kind: .constant |
| 224 | pos: field.pos |
| 225 | return_type: ret_type |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | ast.EnumDecl { |
| 231 | node.kind = .enum |
| 232 | if d.extract_vars { |
| 233 | for mut field in stmt.fields { |
| 234 | ret_type := if field.has_expr { |
| 235 | d.expr_typ_to_string(mut field.expr) |
| 236 | } else { |
| 237 | 'int' |
| 238 | } |
| 239 | node.children << DocNode{ |
| 240 | name: field.name |
| 241 | kind: .enum_field |
| 242 | parent_name: node.name |
| 243 | pos: field.pos |
| 244 | return_type: ret_type |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | for sa in stmt.attrs { |
| 249 | node.attrs[sa.name] = if sa.has_at { '@[${sa.str()}]' } else { '[${sa.str()}]' } |
| 250 | node.tags << node.attrs[sa.name] |
| 251 | } |
| 252 | } |
| 253 | ast.InterfaceDecl { |
| 254 | node.kind = .interface |
| 255 | } |
| 256 | ast.StructDecl { |
| 257 | node.kind = .struct |
| 258 | if d.extract_vars { |
| 259 | for mut field in stmt.fields { |
| 260 | ret_type := if field.typ == 0 && field.has_default_expr { |
| 261 | d.expr_typ_to_string(mut field.default_expr) |
| 262 | } else { |
| 263 | d.type_to_str(field.typ) |
| 264 | } |
| 265 | node.children << DocNode{ |
| 266 | name: field.name |
| 267 | kind: .struct_field |
| 268 | parent_name: node.name |
| 269 | pos: field.pos |
| 270 | return_type: ret_type |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | for sa in stmt.attrs { |
| 275 | node.attrs[sa.name] = if sa.has_at { '@[${sa.str()}]' } else { '[${sa.str()}]' } |
| 276 | node.tags << node.attrs[sa.name] |
| 277 | } |
| 278 | } |
| 279 | ast.TypeDecl { |
| 280 | node.kind = .typedef |
| 281 | } |
| 282 | ast.FnDecl { |
| 283 | if stmt.is_deprecated { |
| 284 | for sa in stmt.attrs { |
| 285 | if sa.name.starts_with('deprecated') { |
| 286 | node.tags << sa.str() |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | if stmt.is_unsafe { |
| 291 | node.tags << 'unsafe' |
| 292 | } |
| 293 | node.kind = .function |
| 294 | node.return_type = d.type_to_str(stmt.return_type) |
| 295 | if stmt.receiver.typ !in [0, 1] { |
| 296 | method_parent := d.type_to_str(stmt.receiver.typ) |
| 297 | node.kind = .method |
| 298 | if !stmt.is_static_type_method { |
| 299 | node.parent_name = method_parent |
| 300 | } else { |
| 301 | node.parent_name = '' |
| 302 | } |
| 303 | } |
| 304 | if d.extract_vars { |
| 305 | for param in stmt.params { |
| 306 | node.children << DocNode{ |
| 307 | name: param.name |
| 308 | kind: .variable |
| 309 | parent_name: node.name |
| 310 | pos: param.pos |
| 311 | attrs: { |
| 312 | 'mut': param.is_mut.str() |
| 313 | } |
| 314 | return_type: d.type_to_str(param.typ) |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | else { |
| 320 | return error('invalid stmt type to document') |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | included := node.name in d.filter_symbol_names || node.parent_name in d.filter_symbol_names |
| 325 | if d.filter_symbol_names.len != 0 && !included { |
| 326 | return error('not included in the list of symbol names') |
| 327 | } |
| 328 | if d.prefs.os == .all { |
| 329 | d.common_symbols << node.name |
| 330 | } |
| 331 | return node |
| 332 | } |
| 333 | |
| 334 | // file_ast reads the contents of `ast.File` and returns a map of `DocNode`s. |
| 335 | pub fn (mut d Doc) file_ast(mut file_ast ast.File) map[string]DocNode { |
| 336 | mut contents := map[string]DocNode{} |
| 337 | d.fmt.file = file_ast |
| 338 | d.fmt.set_current_module_name(d.orig_mod_name) |
| 339 | d.fmt.process_file_imports(file_ast) |
| 340 | mut last_import_stmt_idx := 0 |
| 341 | for sidx, stmt in file_ast.stmts { |
| 342 | if stmt is ast.Import { |
| 343 | last_import_stmt_idx = sidx |
| 344 | } |
| 345 | } |
| 346 | mut preceding_comments := []DocComment{} |
| 347 | mut collect_post_module_comments := false |
| 348 | mut post_module_comments := []DocComment{} |
| 349 | // mut imports_section := true |
| 350 | for sidx, mut stmt in file_ast.stmts { |
| 351 | if mut stmt is ast.ExprStmt { |
| 352 | // Collect comments |
| 353 | if mut stmt.expr is ast.Comment { |
| 354 | comment := ast_comment_to_doc_comment(stmt.expr) |
| 355 | if collect_post_module_comments { |
| 356 | post_module_comments << comment |
| 357 | } else { |
| 358 | preceding_comments << comment |
| 359 | } |
| 360 | continue |
| 361 | } else if stmt.expr is ast.IfExpr && stmt.expr.is_comptime { |
| 362 | comments := ast_comments_to_doc_comments(stmt.expr.post_comments) |
| 363 | if collect_post_module_comments { |
| 364 | post_module_comments << comments |
| 365 | } else { |
| 366 | preceding_comments << comments |
| 367 | } |
| 368 | continue |
| 369 | } |
| 370 | } |
| 371 | // TODO: Fetch head comment once |
| 372 | if mut stmt is ast.Module { |
| 373 | if !d.with_head { |
| 374 | continue |
| 375 | } |
| 376 | // the previous comments were probably a copyright/license one |
| 377 | module_comment := merge_doc_comments(preceding_comments) |
| 378 | if !d.is_vlib && !module_comment.starts_with('Copyright (c)') && module_comment != '' { |
| 379 | d.head.comments << preceding_comments |
| 380 | } |
| 381 | preceding_comments = [] |
| 382 | collect_post_module_comments = true |
| 383 | continue |
| 384 | } |
| 385 | if collect_post_module_comments { |
| 386 | if post_module_comments.len > 0 { |
| 387 | last_post_module_comment := post_module_comments[post_module_comments.len - 1] |
| 388 | if stmt is ast.Import || last_post_module_comment.pos.line_nr + 1 < stmt.pos.line_nr { |
| 389 | d.head.comments << post_module_comments |
| 390 | } else { |
| 391 | preceding_comments << post_module_comments |
| 392 | } |
| 393 | post_module_comments = [] |
| 394 | } |
| 395 | collect_post_module_comments = false |
| 396 | } |
| 397 | if last_import_stmt_idx > 0 && sidx == last_import_stmt_idx { |
| 398 | // the accumulated comments were interspersed before/between the imports; |
| 399 | // just add them all to the module comments: |
| 400 | if d.with_head { |
| 401 | d.head.comments << preceding_comments |
| 402 | } |
| 403 | preceding_comments = [] |
| 404 | // imports_section = false |
| 405 | } |
| 406 | if stmt is ast.Import { |
| 407 | continue |
| 408 | } |
| 409 | mut node := d.stmt(mut stmt, os.base(file_ast.path)) or { |
| 410 | preceding_comments = [] |
| 411 | continue |
| 412 | } |
| 413 | if node.parent_name !in contents { |
| 414 | parent_node_kind := if node.parent_name == 'Constants' { |
| 415 | SymbolKind.const_group |
| 416 | } else { |
| 417 | SymbolKind.typedef |
| 418 | } |
| 419 | contents[node.parent_name] = DocNode{ |
| 420 | name: node.parent_name |
| 421 | kind: parent_node_kind |
| 422 | } |
| 423 | } |
| 424 | if d.with_comments && preceding_comments.len > 0 { |
| 425 | node.comments << preceding_comments |
| 426 | } |
| 427 | preceding_comments = [] |
| 428 | if node.parent_name.len > 0 { |
| 429 | parent_name := node.parent_name |
| 430 | if node.parent_name == 'Constants' { |
| 431 | node.parent_name = '' |
| 432 | } |
| 433 | contents[parent_name].children << node |
| 434 | } else { |
| 435 | contents[node.name] = node |
| 436 | } |
| 437 | } |
| 438 | if collect_post_module_comments && post_module_comments.len > 0 { |
| 439 | d.head.comments << post_module_comments |
| 440 | } |
| 441 | d.fmt.mod2alias = map[string]string{} |
| 442 | if contents[''].kind != .const_group { |
| 443 | contents.delete('') |
| 444 | } |
| 445 | return contents |
| 446 | } |
| 447 | |
| 448 | // file_ast_with_pos has the same function as the `file_ast` but |
| 449 | // instead returns a list of variables in a given offset-based position. |
| 450 | pub fn (mut d Doc) file_ast_with_pos(mut file_ast ast.File, pos int) map[string]DocNode { |
| 451 | lscope := file_ast.scope.innermost(pos) |
| 452 | mut contents := map[string]DocNode{} |
| 453 | for name, val in lscope.objects { |
| 454 | if val !is ast.Var { |
| 455 | continue |
| 456 | } |
| 457 | mut vr_data := val as ast.Var |
| 458 | l_node := DocNode{ |
| 459 | name: name |
| 460 | pos: vr_data.pos |
| 461 | file_path: file_ast.path |
| 462 | from_scope: true |
| 463 | kind: .variable |
| 464 | return_type: d.expr_typ_to_string(mut vr_data.expr) |
| 465 | } |
| 466 | contents[l_node.name] = l_node |
| 467 | } |
| 468 | return contents |
| 469 | } |
| 470 | |
| 471 | // generate is a `Doc` method that will start documentation |
| 472 | // process based on a file path provided. |
| 473 | pub fn (mut d Doc) generate() ! { |
| 474 | // get all files |
| 475 | d.base_path = if os.is_dir(d.base_path) { |
| 476 | d.base_path |
| 477 | } else { |
| 478 | os.real_path(os.dir(d.base_path)) |
| 479 | } |
| 480 | d.is_vlib = d.base_path.contains('vlib') |
| 481 | project_files := os.ls(d.base_path) or { return err } |
| 482 | v_files := d.prefs.should_compile_filtered_files(d.base_path, project_files) |
| 483 | if v_files.len == 0 { |
| 484 | eprintln('vdoc: No valid V files were found. Skipping folder: ${d.base_path}.') |
| 485 | return |
| 486 | } |
| 487 | // parse files |
| 488 | mut comments_mode := scanner.CommentsMode.skip_comments |
| 489 | if d.with_comments { |
| 490 | comments_mode = .parse_comments |
| 491 | } |
| 492 | mut file_asts := []ast.File{} |
| 493 | for i, file_path in v_files { |
| 494 | if i == 0 { |
| 495 | d.parent_mod_name = get_parent_mod(d.base_path) or { '' } |
| 496 | } |
| 497 | file_asts << parser.parse_file(file_path, mut d.table, comments_mode, d.prefs) |
| 498 | } |
| 499 | mut generated_file_asts := []&ast.File{} |
| 500 | parser.append_codegen_files(mut generated_file_asts) |
| 501 | for generated_file_ast in generated_file_asts { |
| 502 | file_asts << *generated_file_ast |
| 503 | } |
| 504 | return d.file_asts(mut file_asts) |
| 505 | } |
| 506 | |
| 507 | // file_asts has the same function as the `file_ast` function but |
| 508 | // accepts an array of `ast.File` and throws an error if necessary. |
| 509 | pub fn (mut d Doc) file_asts(mut file_asts []ast.File) ! { |
| 510 | mut fname_has_set := false |
| 511 | d.orig_mod_name = file_asts[0].mod.name |
| 512 | for i, mut file_ast in file_asts { |
| 513 | if d.filename.len > 0 && file_ast.path.contains(d.filename) && !fname_has_set { |
| 514 | d.filename = file_ast.path |
| 515 | fname_has_set = true |
| 516 | } |
| 517 | if d.with_head && i == 0 { |
| 518 | mut module_name := file_ast.mod.name |
| 519 | if module_name != file_ast.mod.short_name |
| 520 | && !module_name.ends_with('.${file_ast.mod.short_name}') { |
| 521 | // qualify_module resolved by path instead of module name, |
| 522 | // use the short name from the source |
| 523 | module_name = file_ast.mod.short_name |
| 524 | } |
| 525 | d.head = DocNode{ |
| 526 | name: module_name |
| 527 | content: 'module ${module_name}' |
| 528 | kind: .none |
| 529 | } |
| 530 | } else if file_ast.mod.name != d.orig_mod_name { |
| 531 | continue |
| 532 | } |
| 533 | if file_ast.path == d.filename { |
| 534 | d.checker.check(mut file_ast) |
| 535 | d.scoped_contents = d.file_ast_with_pos(mut file_ast, d.pos) |
| 536 | } |
| 537 | contents := d.file_ast(mut file_ast) |
| 538 | for name, node in contents { |
| 539 | if name !in d.contents { |
| 540 | d.contents[name] = node |
| 541 | continue |
| 542 | } |
| 543 | if d.contents[name].kind == .typedef && node.kind !in [.typedef, .none] { |
| 544 | old_children := d.contents[name].children.clone() |
| 545 | d.contents[name] = node |
| 546 | d.contents[name].children = old_children |
| 547 | } |
| 548 | if d.contents[name].kind != .none || node.kind == .none { |
| 549 | d.contents[name].children << node.children |
| 550 | d.contents[name].children.arrange() |
| 551 | } |
| 552 | } |
| 553 | } |
| 554 | if d.filter_symbol_names.len != 0 && d.contents.len != 0 { |
| 555 | for filter_name in d.filter_symbol_names { |
| 556 | if filter_name !in d.contents { |
| 557 | return error('vdoc: `${filter_name}` symbol in module `${d.orig_mod_name}` not found') |
| 558 | } |
| 559 | } |
| 560 | } |
| 561 | d.time_generated = time.now() |
| 562 | } |
| 563 | |
| 564 | // generate documents a certain file directory and returns an |
| 565 | // instance of `Doc` if it is successful. Otherwise, it will throw an error. |
| 566 | pub fn generate(input_path string, pub_only bool, with_comments bool, platform Platform, filter_symbol_names ...string) !Doc { |
| 567 | if platform == .js { |
| 568 | return error('vdoc: Platform `${platform}` is not supported.') |
| 569 | } |
| 570 | mut d := new(input_path) |
| 571 | d.pub_only = pub_only |
| 572 | d.with_comments = with_comments |
| 573 | d.filter_symbol_names = filter_symbol_names.filter(it.len != 0) |
| 574 | d.prefs.os = if platform == .auto { |
| 575 | pref.get_host_os() |
| 576 | } else { |
| 577 | unsafe { pref.OS(int(platform)) } |
| 578 | } |
| 579 | d.generate()! |
| 580 | return d |
| 581 | } |
| 582 | |
| 583 | // generate_with_pos has the same function as the `generate` function but |
| 584 | // accepts an offset-based position and enables the comments by default. |
| 585 | pub fn generate_with_pos(input_path string, filename string, pos int) !Doc { |
| 586 | mut d := new(input_path) |
| 587 | d.pub_only = false |
| 588 | d.with_comments = true |
| 589 | d.with_pos = true |
| 590 | d.filename = filename |
| 591 | d.pos = pos |
| 592 | d.generate()! |
| 593 | return d |
| 594 | } |
| 595 | |