| 1 | module main |
| 2 | |
| 3 | import markdown |
| 4 | import os |
| 5 | import time |
| 6 | import strings |
| 7 | import runtime |
| 8 | import document as doc |
| 9 | import v.vmod |
| 10 | import v.util |
| 11 | import json |
| 12 | import term |
| 13 | |
| 14 | struct Readme { |
| 15 | frontmatter map[string]string |
| 16 | content string |
| 17 | path string |
| 18 | } |
| 19 | |
| 20 | enum OutputType { |
| 21 | unset |
| 22 | none |
| 23 | html |
| 24 | markdown |
| 25 | json |
| 26 | ansi // text with ANSI color escapes |
| 27 | plaintext |
| 28 | } |
| 29 | |
| 30 | @[heap] |
| 31 | struct VDoc { |
| 32 | cfg Config @[required] |
| 33 | mut: |
| 34 | docs []doc.Doc |
| 35 | assets map[string]string |
| 36 | manifest vmod.Manifest |
| 37 | search_index []string |
| 38 | search_data []SearchResult |
| 39 | search_module_index []string // search results are split into a module part and the rest |
| 40 | search_module_data []SearchModuleResult |
| 41 | example_failures int // how many times an example failed to compile or run with non 0 exit code; when positive, finish with exit code 1 |
| 42 | example_oks int // how many ok examples were found when `-run-examples` was passed, that compiled and finished with 0 exit code. |
| 43 | } |
| 44 | |
| 45 | // |
| 46 | struct Output { |
| 47 | mut: |
| 48 | path string |
| 49 | typ OutputType = .unset |
| 50 | } |
| 51 | |
| 52 | struct ParallelDoc { |
| 53 | d doc.Doc |
| 54 | out Output |
| 55 | } |
| 56 | |
| 57 | fn (vd &VDoc) gen_json(d doc.Doc) string { |
| 58 | cfg := vd.cfg |
| 59 | mut jw := strings.new_builder(200) |
| 60 | jw.write_string('{"module_name":"${d.head.name}",') |
| 61 | if d.head.comments.len > 0 && cfg.include_comments { |
| 62 | comments := if cfg.include_examples { |
| 63 | d.head.merge_comments() |
| 64 | } else { |
| 65 | d.head.merge_comments_without_examples() |
| 66 | } |
| 67 | jw.write_string('"description":"${escape(comments)}",') |
| 68 | } |
| 69 | jw.write_string('"contents":') |
| 70 | jw.write_string(json.encode(d.contents.keys().map(d.contents[it]))) |
| 71 | jw.write_string(',"generator":"vdoc","time_generated":"${d.time_generated.str()}"}') |
| 72 | return jw.str() |
| 73 | } |
| 74 | |
| 75 | fn (mut vd VDoc) gen_plaintext(d doc.Doc) string { |
| 76 | cfg := vd.cfg |
| 77 | mut pw := strings.new_builder(200) |
| 78 | if cfg.is_color { |
| 79 | content_arr := d.head.content.split(' ') |
| 80 | pw.writeln('${term.bright_blue(content_arr[0])} ${term.green(content_arr[1])}') |
| 81 | } else { |
| 82 | pw.writeln('${d.head.content}') |
| 83 | } |
| 84 | if cfg.include_comments { |
| 85 | comments := if cfg.include_examples { |
| 86 | d.head.merge_comments() |
| 87 | } else { |
| 88 | d.head.merge_comments_without_examples() |
| 89 | } |
| 90 | if comments.trim_space().len > 0 { |
| 91 | pw.writeln(indent(comments)) |
| 92 | } |
| 93 | } |
| 94 | pw.writeln('') |
| 95 | vd.write_plaintext_content(d.contents.arr(), mut pw) |
| 96 | return pw.str() |
| 97 | } |
| 98 | |
| 99 | fn indent(s string) string { |
| 100 | return ' ' + s.replace('\n', '\n ') |
| 101 | } |
| 102 | |
| 103 | fn dn_to_location(cn doc.DocNode) string { |
| 104 | location := '${util.path_styled_for_error_messages(cn.file_path)}:${cn.pos.line_nr + 1:-4}' |
| 105 | if location.len > 24 { |
| 106 | return '${location:-38s} ' |
| 107 | } |
| 108 | return '${location:-24s} ' |
| 109 | } |
| 110 | |
| 111 | fn write_location(cn doc.DocNode, mut pw strings.Builder) { |
| 112 | pw.write_string(dn_to_location(cn)) |
| 113 | } |
| 114 | |
| 115 | fn (mut vd VDoc) write_plaintext_content(contents []doc.DocNode, mut pw strings.Builder) { |
| 116 | cfg := vd.cfg |
| 117 | for cn in contents { |
| 118 | if cn.content.len > 0 { |
| 119 | if cfg.show_loc { |
| 120 | write_location(cn, mut pw) |
| 121 | } |
| 122 | if cfg.is_color { |
| 123 | pw.writeln(color_highlight(cn.content, vd.docs[0].table)) |
| 124 | } else { |
| 125 | pw.writeln(cn.content) |
| 126 | } |
| 127 | if cn.comments.len > 0 && cfg.include_comments { |
| 128 | comments := cn.merge_comments_without_examples() |
| 129 | pw.writeln(indent(comments.trim_space())) |
| 130 | if cfg.include_examples { |
| 131 | examples := cn.examples() |
| 132 | for ex in examples { |
| 133 | pw.write_string(' Example: ') |
| 134 | mut fex := ex |
| 135 | if ex.index_u8(`\n`) >= 0 { |
| 136 | // multi-line example |
| 137 | pw.write_u8(`\n`) |
| 138 | fex = indent(ex) |
| 139 | } |
| 140 | if cfg.is_color { |
| 141 | fex = color_highlight(fex, vd.docs[0].table) |
| 142 | } |
| 143 | pw.writeln(fex) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | vd.write_plaintext_content(cn.children, mut pw) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | fn (mut vd VDoc) render_doc(d doc.Doc, out Output) (string, string) { |
| 153 | name := vd.get_file_name(d.head.name, out) |
| 154 | output := match out.typ { |
| 155 | .none { '' } |
| 156 | .html { vd.gen_html(d) } |
| 157 | .markdown { vd.gen_markdown(d, true) } |
| 158 | .json { vd.gen_json(d) } |
| 159 | else { vd.gen_plaintext(d) } |
| 160 | } |
| 161 | |
| 162 | contents := d.contents.arr() |
| 163 | vd.process_all_examples(contents) |
| 164 | return name, output |
| 165 | } |
| 166 | |
| 167 | // get_file_name returns the final file name from a module name |
| 168 | fn (vd &VDoc) get_file_name(mod string, out Output) string { |
| 169 | cfg := vd.cfg |
| 170 | mut name := mod |
| 171 | if mod == 'README' { |
| 172 | name = 'index' |
| 173 | } else if !cfg.is_multi && !os.is_dir(out.path) { |
| 174 | name = os.file_name(out.path) |
| 175 | } |
| 176 | if name == '' { |
| 177 | name = 'index' |
| 178 | } |
| 179 | name = name + match out.typ { |
| 180 | .html { '.html' } |
| 181 | .markdown { '.md' } |
| 182 | .json { '.json' } |
| 183 | else { '.txt' } |
| 184 | } |
| 185 | |
| 186 | return name |
| 187 | } |
| 188 | |
| 189 | fn (mut vd VDoc) work_processor(work chan ParallelDoc) { |
| 190 | for { |
| 191 | pdoc := <-work or { break } |
| 192 | vd.vprintln('> start processing ${pdoc.d.base_path} ...') |
| 193 | file_name, content := vd.render_doc(pdoc.d, pdoc.out) |
| 194 | if vd.cfg.output_type != .none { |
| 195 | output_path := os.join_path(pdoc.out.path, file_name) |
| 196 | println('Generating ${content.len:8} bytes of ${pdoc.out.typ} in `${output_path}` ...') |
| 197 | os.write_file(output_path, content) or { panic(err) } |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | fn (mut vd VDoc) render_parallel(out Output) { |
| 203 | mut work := chan ParallelDoc{cap: vd.docs.len} |
| 204 | for i in 0 .. vd.docs.len { |
| 205 | work <- ParallelDoc{vd.docs[i], out} |
| 206 | } |
| 207 | work.close() |
| 208 | |
| 209 | vjobs := runtime.nr_jobs() |
| 210 | mut worker_threads := []thread{cap: vjobs} |
| 211 | for _ in 0 .. vjobs { |
| 212 | worker_threads << spawn vd.work_processor(work) |
| 213 | } |
| 214 | worker_threads.wait() |
| 215 | } |
| 216 | |
| 217 | fn (mut vd VDoc) render(out Output) map[string]string { |
| 218 | mut docs := map[string]string{} |
| 219 | for doc in vd.docs { |
| 220 | name, output := vd.render_doc(doc, out) |
| 221 | docs[name] = output.trim_space() |
| 222 | } |
| 223 | vd.vprintln('Rendered: ' + docs.keys().str()) |
| 224 | return docs |
| 225 | } |
| 226 | |
| 227 | fn (vd &VDoc) get_readme(path string) Readme { |
| 228 | mut fname := '' |
| 229 | for name in ['readme.md', 'README.md'] { |
| 230 | if os.exists(os.join_path(path, name)) { |
| 231 | fname = name |
| 232 | break |
| 233 | } |
| 234 | } |
| 235 | if fname == '' { |
| 236 | if path.all_after_last(os.path_separator) == 'src' { |
| 237 | next_path := path.all_before_last(os.path_separator) |
| 238 | if next_path != '' && path != next_path && os.is_dir(next_path) { |
| 239 | return vd.get_readme(next_path) |
| 240 | } |
| 241 | } |
| 242 | return Readme{} |
| 243 | } |
| 244 | readme_path := os.join_path(path, fname) |
| 245 | vd.vprintln('Reading README file from ${readme_path}') |
| 246 | mut readme_contents := os.read_file(readme_path) or { '' } |
| 247 | mut readme_frontmatter := map[string]string{} |
| 248 | if readme_contents.starts_with('---\n') { |
| 249 | if frontmatter_lines_end_idx := readme_contents.index('\n---\n') { |
| 250 | front_matter_lines := |
| 251 | readme_contents#[4..frontmatter_lines_end_idx].trim_space().split_into_lines() |
| 252 | for line in front_matter_lines { |
| 253 | x := line.split(': ') |
| 254 | if x.len == 2 { |
| 255 | readme_frontmatter[x[0]] = x[1] |
| 256 | } |
| 257 | } |
| 258 | readme_contents = readme_contents#[5 + frontmatter_lines_end_idx..] |
| 259 | } |
| 260 | } |
| 261 | return Readme{ |
| 262 | frontmatter: readme_frontmatter |
| 263 | content: readme_contents |
| 264 | path: readme_path |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | fn (vd &VDoc) emit_generate_err(err IError) { |
| 269 | cfg := vd.cfg |
| 270 | mut err_msg := err.msg() |
| 271 | if err.code() == 1 { |
| 272 | mod_list := get_modules(cfg.input_path) |
| 273 | println('Available modules:\n==================') |
| 274 | for mod in mod_list { |
| 275 | println(mod.all_after('vlib/').all_after('modules/').replace('/', '.')) |
| 276 | } |
| 277 | err_msg += ' Use the `-m` flag when generating docs from a directory that has multiple modules.' |
| 278 | } |
| 279 | eprintln(err_msg) |
| 280 | } |
| 281 | |
| 282 | fn (mut vd VDoc) generate_docs_from_file() { |
| 283 | sw := time.new_stopwatch() |
| 284 | defer { |
| 285 | if vd.cfg.show_time { |
| 286 | println('Generation took: ${sw.elapsed().milliseconds()} ms.') |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | cfg := vd.cfg |
| 291 | mut out := Output{ |
| 292 | path: cfg.output_path |
| 293 | typ: cfg.output_type |
| 294 | } |
| 295 | if out.path == '' { |
| 296 | if cfg.output_type == .unset { |
| 297 | out.typ = .ansi |
| 298 | } else { |
| 299 | vd.vprintln('No output path has detected. Using input path instead.') |
| 300 | out.path = cfg.input_path |
| 301 | } |
| 302 | } else if out.typ == .unset { |
| 303 | vd.vprintln('Output path detected. Identifying output type..') |
| 304 | ext := os.file_ext(out.path) |
| 305 | out.typ = set_output_type_from_str(ext.all_after('.')) |
| 306 | } |
| 307 | if cfg.include_readme && out.typ !in [.html, .ansi, .plaintext, .none] { |
| 308 | eprintln('vdoc: Including README.md for doc generation is supported on HTML output, or when running directly in the terminal.') |
| 309 | exit(1) |
| 310 | } |
| 311 | dir_path := if cfg.is_vlib { |
| 312 | os.join_path(vroot, 'vlib') |
| 313 | } else if os.is_dir(cfg.input_path) { |
| 314 | cfg.input_path |
| 315 | } else { |
| 316 | os.dir(cfg.input_path) |
| 317 | } |
| 318 | manifest_path := if cfg.is_vlib { |
| 319 | os.join_path(vroot, 'v.mod') |
| 320 | } else { |
| 321 | os.join_path(dir_path, 'v.mod') |
| 322 | } |
| 323 | if os.exists(manifest_path) { |
| 324 | vd.vprintln('Reading v.mod info from ${manifest_path}') |
| 325 | if manifest := vmod.from_file(manifest_path) { |
| 326 | vd.manifest = manifest |
| 327 | } |
| 328 | } else if cfg.is_vlib { |
| 329 | assert false, 'vdoc: manifest does not exist for vlib' |
| 330 | } |
| 331 | if cfg.include_readme || cfg.is_vlib { |
| 332 | mut readme_name := 'README' |
| 333 | readme := vd.get_readme(dir_path) |
| 334 | if page := readme.frontmatter['page'] { |
| 335 | readme_name = page |
| 336 | } |
| 337 | comment := doc.DocComment{ |
| 338 | is_readme: true |
| 339 | frontmatter: readme.frontmatter |
| 340 | text: readme.content |
| 341 | } |
| 342 | if out.typ == .ansi { |
| 343 | println(markdown.to_plain(readme.content)) |
| 344 | } else if out.typ == .html && cfg.is_multi { |
| 345 | vd.docs << doc.Doc{ |
| 346 | head: doc.DocNode{ |
| 347 | is_readme: true |
| 348 | name: readme_name |
| 349 | file_path: readme.path |
| 350 | frontmatter: readme.frontmatter |
| 351 | comments: [comment] |
| 352 | } |
| 353 | time_generated: time.now() |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | dirs := if cfg.is_multi { get_modules(cfg.input_path) } else { [cfg.input_path] } |
| 358 | for dirpath in dirs { |
| 359 | vd.vprintln('Generating ${out.typ} docs for "${dirpath}"') |
| 360 | mut dcs := doc.generate(dirpath, cfg.pub_only, true, cfg.platform, cfg.symbol_name) or { |
| 361 | // TODO: use a variable like `src_path := os.join_path(dirpath, 'src')` after `https://github.com/vlang/v/issues/21504` |
| 362 | if os.exists(os.join_path(dirpath, 'src')) { |
| 363 | doc.generate(os.join_path(dirpath, 'src'), cfg.pub_only, true, cfg.platform, |
| 364 | cfg.symbol_name) or { |
| 365 | vd.emit_generate_err(err) |
| 366 | exit(1) |
| 367 | } |
| 368 | } else { |
| 369 | vd.emit_generate_err(err) |
| 370 | exit(1) |
| 371 | } |
| 372 | } |
| 373 | if cfg.is_multi || (!cfg.is_multi && cfg.include_readme) { |
| 374 | readme := vd.get_readme(dirpath) |
| 375 | if readme.path != '' { |
| 376 | comment := doc.DocComment{ |
| 377 | is_readme: true |
| 378 | frontmatter: readme.frontmatter |
| 379 | text: readme.content |
| 380 | } |
| 381 | dcs.head.comments = [comment] |
| 382 | dcs.head.file_path = readme.path |
| 383 | } |
| 384 | } |
| 385 | if cfg.pub_only { |
| 386 | for name, dc in dcs.contents { |
| 387 | dcs.contents[name].content = dc.content.all_after('pub ') |
| 388 | for i, cc in dc.children { |
| 389 | dcs.contents[name].children[i].content = cc.content.all_after('pub ') |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | vd.docs << dcs |
| 394 | } |
| 395 | if dirs.len == 0 && cfg.is_multi { |
| 396 | eprintln('vdoc: -m requires at least 1 module folder') |
| 397 | exit(1) |
| 398 | } |
| 399 | vd.vprintln('Rendering docs...') |
| 400 | if out.path == '' || out.path == 'stdout' || out.path == '-' { |
| 401 | if out.typ == .html { |
| 402 | vd.render_static_html(out) |
| 403 | } |
| 404 | outputs := vd.render(out) |
| 405 | if outputs.len == 0 { |
| 406 | if dirs.len == 0 { |
| 407 | eprintln('vdoc: No documentation found') |
| 408 | } else { |
| 409 | eprintln('vdoc: No documentation found for ${dirs[0]}') |
| 410 | } |
| 411 | exit(1) |
| 412 | } else { |
| 413 | first := outputs.keys()[0] |
| 414 | println(outputs[first]) |
| 415 | } |
| 416 | } else { |
| 417 | if !os.exists(out.path) { |
| 418 | os.mkdir_all(out.path) or { panic(err) } |
| 419 | } else if !os.is_dir(out.path) { |
| 420 | out.path = os.real_path('.') |
| 421 | } |
| 422 | if cfg.is_multi { |
| 423 | out.path = if cfg.input_path == out.path { |
| 424 | os.join_path(out.path, '_docs') |
| 425 | } else { |
| 426 | out.path |
| 427 | } |
| 428 | if !os.exists(out.path) { |
| 429 | os.mkdir(out.path) or { panic(err) } |
| 430 | } else { |
| 431 | for fname in css_js_assets { |
| 432 | existing_asset_path := os.join_path(out.path, fname) |
| 433 | if os.exists(existing_asset_path) { |
| 434 | os.rm(existing_asset_path) or { panic(err) } |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | if out.typ == .html { |
| 440 | vd.render_static_html(out) |
| 441 | } |
| 442 | vd.render_parallel(out) |
| 443 | if out.typ == .html { |
| 444 | println('Creating search index...') |
| 445 | vd.collect_search_index(out) |
| 446 | vd.render_search_index(out) |
| 447 | // move favicons to target directory |
| 448 | println('Copying favicons...') |
| 449 | favicons_path := os.join_path(cfg.theme_dir, 'favicons') |
| 450 | favicons := os.ls(favicons_path) or { panic(err) } |
| 451 | for favicon in favicons { |
| 452 | favicon_path := os.join_path(favicons_path, favicon) |
| 453 | destination_path := os.join_path(out.path, favicon) |
| 454 | os.cp(favicon_path, destination_path) or { panic(err) } |
| 455 | } |
| 456 | println('Copied ${favicons.len} icons to `${out.path}` .') |
| 457 | } |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | fn (vd &VDoc) vprintln(str string) { |
| 462 | if vd.cfg.is_verbose { |
| 463 | println('vdoc: ${str}') |
| 464 | } |
| 465 | } |
| 466 | |