// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. module main import os import os.cmdline import rand import term import v.help import regex const too_long_line_length_example = 120 const too_long_line_length_codeblock = 120 const too_long_line_length_table = 160 const too_long_line_length_link = 250 const too_long_line_length_other = 100 const term_colors = term.can_show_color_on_stderr() const hide_warnings = '-hide-warnings' in os.args || '-w' in os.args const show_progress = os.getenv('GITHUB_JOB') == '' && '-silent' !in os.args const non_option_args = cmdline.only_non_options(os.args[2..]) const is_verbose = os.getenv('VERBOSE') != '' const vcheckfolder = os.join_path(os.vtmp_dir(), 'vcheck_${os.getpid()}') const should_autofix = os.getenv('VAUTOFIX') != '' || '-fix' in os.args const vexe = @VEXE struct CheckResult { pub mut: files int lines int examples int oks int warnings int ferrors int errors int } struct VCheckIgnoreRule { base_dir string pattern string } struct VCheckIgnoreContext { repo_root string } struct VCheckIgnoreMatch { ignore_file string pattern string } struct MDPathScanResult { files []string skipped int } fn (v1 CheckResult) + (v2 CheckResult) CheckResult { return CheckResult{ files: v1.files + v2.files lines: v1.lines + v2.lines examples: v1.examples + v2.examples oks: v1.oks + v2.oks warnings: v1.warnings + v2.warnings ferrors: v1.ferrors + v2.ferrors errors: v1.errors + v2.errors } } fn main() { unbuffer_stdout() if non_option_args.len == 0 || '-help' in os.args { help.print_and_exit('check-md') } if '-all' in os.args { println('´-all´ flag is deprecated. Please use ´v check-md .´ instead.') exit(1) } mut skip_line_length_check := '-skip-line-length-check' in os.args mut files_paths := non_option_args.clone() mut res := CheckResult{} if term_colors { os.setenv('VCOLORS', 'always', true) } os.mkdir_all(vcheckfolder, mode: 0o700) or {} // keep directory private defer { os.rmdir_all(vcheckfolder) or {} } mut all_mdfiles := []MDFile{} mut skipped_mdfiles := 0 for i := 0; i < files_paths.len; i++ { file_path := files_paths[i] if os.is_dir(file_path) { scan_result := md_file_paths(file_path) files_paths << scan_result.files skipped_mdfiles += scan_result.skipped continue } real_path := os.real_path(file_path) lines := os.read_lines(real_path) or { println('"${file_path}" does not exist') res.warnings++ continue } all_mdfiles << MDFile{ skip_line_length_check: skip_line_length_check path: file_path lines: lines } } println('> Found: ${all_mdfiles.len} .md files. Skipped by .vcheckignore: ${skipped_mdfiles}.') if is_verbose { for idx, mdfile in all_mdfiles { println('> file ${idx + 1} is ${mdfile.path}') } } if show_progress { // this is intended to be replaced by the progress lines println('') } for idx, mut mdfile in all_mdfiles { mdfile.idx = idx mdfile.nfiles = all_mdfiles.len res += mdfile.check() } if res.errors == 0 && show_progress { clear_previous_line() } println('Checked .md files: ${res.files} | Ex.: ${res.examples} | Lines: ${res.lines} | OKs: ${res.oks} | Warnings: ${res.warnings} | Errors: ${res.errors} | Fmt errors: ${res.ferrors}') if res.ferrors > 0 && !should_autofix { println('Note: you can use `VAUTOFIX=1 v check-md file.md`, or `v check-md -fix file.md`,') println(' to fix the V formatting errors in the markdown code blocks, when possible.') println(' Run the command 2 times, to verify that all formatting errors were fixed.') println('Note: `v help check-md` shows a list of ```v fence keywords (for partial code).') } if res.errors > 0 { exit(1) } } fn md_file_paths(dir string) MDPathScanResult { mut files_to_check := []string{} mut skipped := 0 vcheckignore := collect_vcheckignore_context(dir) md_files := os.walk_ext(dir, '.md') for file in md_files { nfile := file.replace('\\', '/') if nfile.contains_any_substr(['/thirdparty/', 'CHANGELOG', '/testdata/']) { continue } if skip_match := vcheckignore.skip_match(file) { if is_verbose { println('SKIP: ${vcheckignore.repo_relative_path(file)} (from ${vcheckignore.repo_relative_path(skip_match.ignore_file)}: ${skip_match.pattern})') } skipped++ continue } files_to_check << file } return MDPathScanResult{ files: files_to_check skipped: skipped } } fn collect_vcheckignore_context(cwd string) VCheckIgnoreContext { repo_root := find_repo_root(cwd) return VCheckIgnoreContext{ repo_root: repo_root } } fn find_repo_root(cwd string) string { mut dir := os.real_path(cwd) for { if os.exists(os.join_path(dir, '.git')) { return dir } parent := os.dir(dir) if parent == dir || parent == '' { return dir } dir = parent } return dir } fn (ctx VCheckIgnoreContext) skip_match(file_path string) ?VCheckIgnoreMatch { file := os.real_path(file_path).replace('\\', '/') mut dir := os.dir(file) repo_root := ctx.repo_root.replace('\\', '/') for { ignore_path := os.join_path(dir, '.vcheckignore') if os.is_file(ignore_path) { lines := os.read_lines(ignore_path) or { []string{} } for line in lines { pattern := normalize_vcheckignore_line(line) if pattern == '' || pattern.starts_with('#') { continue } if matches_vcheckignore_rule(file, VCheckIgnoreRule{ base_dir: dir pattern: pattern }) { return VCheckIgnoreMatch{ ignore_file: ignore_path pattern: pattern } } } } if dir.replace('\\', '/') == repo_root { break } parent := os.dir(dir) if parent == dir || parent == '' { break } dir = parent } return none } fn normalize_vcheckignore_line(line string) string { trimmed := line.trim_space() if trimmed == '' { return '' } if comment_idx := trimmed.index('#') { return trimmed[..comment_idx].trim_space() } return trimmed } fn (ctx VCheckIgnoreContext) repo_relative_path(file_path string) string { file := os.real_path(file_path).replace('\\', '/') root := ctx.repo_root.replace('\\', '/') root_prefix := root + '/' if file.starts_with(root_prefix) { return file.all_after(root_prefix) } return file } fn matches_vcheckignore_rule(file string, rule VCheckIgnoreRule) bool { base := rule.base_dir.replace('\\', '/') base_prefix := base + '/' if !file.starts_with(base_prefix) { return false } relative_file := file.all_after(base_prefix) mut pattern := rule.pattern.replace('\\', '/') if pattern.starts_with('!') { return false } mut anchored := false if pattern.starts_with('/') { anchored = true pattern = pattern.trim_left('/') } if pattern.ends_with('/') { pattern = pattern.trim_right('/') return matches_vcheckignore_directory_pattern(relative_file, pattern, anchored) } if anchored { return relative_file.match_glob(pattern) } if pattern.contains('/') { return relative_file.match_glob(pattern) } return os.file_name(relative_file).match_glob(pattern) } fn matches_vcheckignore_directory_pattern(relative_file string, pattern string, anchored bool) bool { mut relative_dir := os.dir(relative_file).replace('\\', '/') if relative_dir == '.' || relative_dir == '' { return false } if anchored { return relative_dir.match_glob(pattern) || relative_dir.match_glob(pattern + '/*') } mut candidate := relative_dir for { if candidate.match_glob(pattern) || candidate.match_glob(pattern + '/*') { return true } if slash_idx := candidate.index('/') { candidate = candidate[slash_idx + 1..] continue } break } return false } fn wprintln(s string) { if !hide_warnings { println(s) } } fn ftext(s string, cb fn (string) string) string { if term_colors { return cb(s) } return s } fn btext(s string) string { return ftext(s, term.bold) } fn mtext(s string) string { return ftext(s, term.magenta) } fn rtext(s string) string { return ftext(s, term.red) } fn wline(file_path string, lnumber int, column int, message string) string { return btext('${file_path}:${lnumber + 1}:${column + 1}:') + btext(mtext(' warn:')) + rtext(' ${message}') } fn eline(file_path string, lnumber int, column int, message string) string { return btext('${file_path}:${lnumber + 1}:${column + 1}:') + btext(rtext(' error: ${message}')) } const default_command = 'compile' struct VCodeExample { mut: text []string command string sline int eline int } enum MDFileParserState { markdown vexample codeblock } struct MDFile { path string skip_line_length_check bool mut: idx int nfiles int lines []string examples []VCodeExample current VCodeExample state MDFileParserState = .markdown oks int warnings int errors int // compilation errors + formatting errors ferrors int // purely formatting errors } fn (mut f MDFile) progress(message string) { if show_progress { clear_previous_line() println('${message} | File ${f.idx + 1:3}/${f.nfiles:-3}: ${f.path}') } } struct CheckResultContext { path string line_number int line string } fn (mut f MDFile) wcheck(actual int, limit int, ctx CheckResultContext, msg_template string) { if actual > limit { final := msg_template.replace('@', limit.str()) + ', actual: ' + actual.str() wprintln(wline(ctx.path, ctx.line_number, ctx.line.len, final)) wprintln(ctx.line) wprintln(ftext('-'.repeat(limit) + '^', term.gray)) f.warnings++ } } fn (mut f MDFile) echeck(actual int, limit int, ctx CheckResultContext, msg_template string) { if actual > limit { final := msg_template.replace('@', limit.str()) + ', actual: ' + actual.str() eprintln(eline(ctx.path, ctx.line_number, ctx.line.len, final)) eprintln(ctx.line) eprintln(ftext('-'.repeat(limit) + '^', term.gray)) f.errors++ } } fn (mut f MDFile) check() CheckResult { mut anchor_data := AnchorData{} for j, line in f.lines { // f.progress('line: ${j}') if !f.skip_line_length_check { ctx := CheckResultContext{f.path, j, line} if f.state == .vexample { f.wcheck(line.len, too_long_line_length_example, ctx, 'example lines must be less than @ characters') } else if f.state == .codeblock { f.wcheck(line.len, too_long_line_length_codeblock, ctx, 'code lines must be less than @ characters') } else if line.starts_with('|') { f.wcheck(line.len, too_long_line_length_table, ctx, 'table lines must be less than @ characters') } else if line.contains('http') { // vfmt off f.wcheck(line.all_after('https').len, too_long_line_length_link, ctx, 'link lines must be less than @ characters') // vfmt on } else { f.echeck(line.len, too_long_line_length_other, ctx, 'must be less than @ characters') } } if f.state == .markdown { anchor_data.add_links(j, line) anchor_data.add_link_targets(j, line) } f.parse_line(j, line) } f.check_link_target_match(anchor_data) f.check_examples() return CheckResult{ files: 1 lines: f.lines.len examples: f.examples.len oks: f.oks warnings: f.warnings errors: f.errors ferrors: f.ferrors } } fn (mut f MDFile) parse_line(lnumber int, line string) { if line.starts_with('```v') { if f.state == .markdown { f.state = .vexample mut command := line.replace('```v', '').trim_space() if command == '' { command = default_command } else if command == 'nofmt' { command += ' ${default_command}' } f.current = VCodeExample{ sline: lnumber command: command } } return } if line.starts_with('```') { match f.state { .vexample { f.state = .markdown f.current.eline = lnumber f.examples << f.current f.current = VCodeExample{} return } .codeblock { f.state = .markdown return } .markdown { f.state = .codeblock return } } } if f.state == .vexample { f.current.text << line } } struct Headline { line int label string level int } struct Anchor { line int } type AnchorTarget = Anchor | Headline struct AnchorLink { line int label string } struct AnchorData { mut: links map[string][]AnchorLink anchors map[string][]AnchorTarget } fn (mut ad AnchorData) add_links(line_number int, line string) { query := r'\[(?P