v/cmd/tools/changelog_helper.v
Alexander Medvednikov b9e5757236 V 0.4.11
2025-06-19 03:13:15 +03:00

583 lines
11 KiB
V

module main
import os
const delete_skipped = true
const git_log_cmd = 'git log -n 500 --pretty=format:"%s" --simplify-merges'
enum Category {
checker
breaking
improvements
parser
stdlib
web
orm
db
native
cgen
js_backend
comptime
tools
compiler_internals
examples
vfmt
os_support
}
const category_titles = '#### Improvements in the language
#### Breaking changes
#### Checker improvements/fixes
#### Parser improvements
#### Compiler internals
#### Standard library
#### Web
#### ORM
#### Database drivers
#### Native backend
#### C backend
#### JavaScript backend
#### vfmt
#### Tools
#### Operating System support
#### Examples
'
struct Line {
category Category
text string
}
const log_txt = 'log.txt'
struct App {
version string // e.g. "0.4.5"
total_lines int
mut:
result string // resulting CHANGELOG.md
counter int
}
const is_interactive = false
// Instantly updates CHANGELOG.md without confirming each line
fn no_interactive(version string) {
}
fn main() {
mut version := ''
if os.args.len == 2 && os.args[1].starts_with('0.') {
version = os.args[1]
// no_interactive(version)
// return
} else {
println('Usage: v run tools/changelog_helper.v 0.4.5')
return
}
if !os.exists(log_txt) {
os.execute(git_log_cmd + ' > ' + log_txt)
println('log.txt generated')
// println('log.txt generated, remove unnecessary commits from it and run the tool again')
// return
}
mut lines := os.read_lines(log_txt)!
// Trim everything before current version, commit "(tag: 0.4.4) V 0.4.4"
mut prev_version := (version.replace('.', '').int() - 1).str()
prev_version = '0.${prev_version[0].ascii_str()}.${prev_version[1].ascii_str()}'
println('prev version=${prev_version}')
for i, line in lines {
if line == ('V ${prev_version}') {
lines = lines[..i].clone()
break
}
}
os.write_file(log_txt, lines.join('\n'))!
if true {
// return
}
mut app := &App{
total_lines: lines.len
}
// Write categories at the top first
app.result = os.read_file('CHANGELOG.md')!.replace_once('V ${version} TODO', 'V ${version}\n' +
category_titles)
os.write_file('CHANGELOG.md', app.result)!
changelog_txt := os.read_file('CHANGELOG.md')!.to_lower()
if true {
// println(changelog_txt)
// return
}
// mut counter := 0 // to display how many commits are left
for line in lines {
s := line.trim_space()
if s == '' {
app.counter++
}
}
// println('${counter} / ${lines.len}')
for line in lines {
s := line.to_lower()
if line != '' && (changelog_txt.contains(s) || changelog_txt.contains(s.after(':'))) {
println('Duplicate: "${line}"')
// skip duplicate
delete_processed_line_from_log(line)!
continue
}
app.process_line(line.trim_space())!
}
println('writing changelog.md')
if !is_interactive {
os.write_file('CHANGELOG.md', app.result)!
}
println('done.')
}
fn (mut app App) process_line(text string) ! {
if text == '' {
return
}
semicolon_pos := text.index(': ') or {
println('no `:` in commit, skipping: "${text}"')
return
}
prefix := text[..semicolon_pos]
// Get category based on keywords in the commit message/prefix
mut category := Category.examples
if text.contains('checker:') {
category = .checker
} else if is_examples(text) {
category = .examples
// println("Skipping line (example) $text")
// return
} else if is_skip(text) {
// Always skip cleanups, typos etc
println('Skipping line (cleanup/typo)\n${text}\n')
if delete_skipped {
delete_processed_line_from_log(text)!
}
return
} else if is_os_support(text) {
category = .os_support
} else if is_cgen(text) {
category = .cgen
} else if is_js_backend(text) {
category = .js_backend
} else if is_comptime(text) {
category = .comptime
} else if is_db(text) {
category = .db
} else if is_stdlib(text) {
category = .stdlib
} else if is_orm(text) {
category = .orm
} else if is_web(text) {
category = .web
} else if is_tools(text) {
category = .tools
} else if is_parser(text) {
category = .parser
} else if is_internal(text) {
category = .compiler_internals
} else if is_improvements(text) {
category = .improvements
} else if is_native(text) {
category = .native
} else if is_vfmt(text) {
category = .vfmt
} else if text.contains('docs:') || text.contains('doc:') {
// Always skip docs
delete_processed_line_from_log(text)!
return
} else {
println('Skipping line (unknown category)\n${text}\n')
// if delete_skipped {
// delete_processed_line_from_log(text)!
//}
return
}
println('process_line: cat=${category} "${text}"')
// Trim everything to the left of `:` for some commits (e.g. `checker: `)
mut s := text
// println("PREFIX='${prefix}'")
// if true {
// exit(0)
//}
if (semicolon_pos < 15
&& prefix in ['checker', 'cgen', 'orm', 'parser', 'v.parser', 'native', 'ast', 'jsgen', 'v.gen.js', 'fmt', 'vfmt', 'tools', 'examples'])
|| (semicolon_pos < 30 && prefix.contains(', ')) {
s = '- ' + text[semicolon_pos + 2..].capitalize()
}
if is_interactive {
// Get input from the user
print('\033[H\033[J')
println('${app.counter} / ${app.total_lines}')
// println('\n')
println(text)
input := os.input('${category}? ')
println("INPUT='${input}'")
match input {
'' {
println('GOT ENTER')
line := Line{category, s}
save_line_interactive(line)!
}
'n', '0', 'no' {
// Ignore commit
println('ignored.')
}
's', 'skip' {
// Skip
println('skipped.')
return
}
'c', 'change' {
// Change category
for {
print_category_hint()
custom_category := os.input('${category} ?').int()
if custom_category == 0 {
println('wrong category')
} else {
unsafe {
line := Line{Category(custom_category - 1), s}
save_line_interactive(line)!
}
break
}
}
}
else {}
}
app.counter++
} else {
line := Line{category, s}
app.save_line(line)!
}
// Don't forget to remove the line we just processed from log.txt
delete_processed_line_from_log(text)!
}
fn (mut app App) save_line(line Line) ! {
// println('save line ${line}')
app.result = line.write_at_category(app.result) or { return error('') }
}
fn save_line_interactive(line Line) ! {
println('save line interactive ${line}')
mut txt := os.read_file('CHANGELOG.md')!
txt = line.write_at_category(txt) or { return error('') }
os.write_file('CHANGELOG.md', txt)!
}
const category_map = {
Category.checker: '#### Checker improvements'
.breaking: '#### Breaking changes'
.improvements: '#### Improvements in the'
.parser: '#### Parser improvements'
.stdlib: '#### Standard library'
.web: '#### Web'
.orm: '#### ORM'
.db: '#### Database drivers'
.native: '#### Native backend'
.cgen: '#### C backend'
.js_backend: '#### JavaScript backend'
.comptime: '#### Comptime'
.tools: '#### Tools'
.compiler_internals: '#### Compiler internals'
.examples: '#### Examples'
.vfmt: '#### vfmt'
.os_support: '#### Operating System'
}
fn (l Line) write_at_category(txt string) ?string {
title := category_map[l.category]
title_pos := txt.index(title)?
// Find the position of the ### category title
pos := txt.index_after('\n', title_pos + 1) or { return none }
first_half := txt[..pos]
second_half := txt[pos..]
if txt.contains(l.text) {
// Avoid duplicates (just in case)
println("Got a duplicate: '${txt}'")
return txt
}
// Now insert the line in the middle, under the ### category title
mut line_text := l.text
// Trim "prefix:" for some categories
// mut capitalized := false
mut has_prefix := true
if l.category in [.cgen, .checker, .improvements, .native, .orm] {
has_prefix = false
if semicolon_pos := line_text.index(': ') {
prefix := line_text[..semicolon_pos]
println("PREFIX='${prefix}'")
if semicolon_pos < 15 {
line_text = line_text[semicolon_pos + 2..].capitalize()
// capitalized = true
}
}
}
if !has_prefix {
line_text = line_text.capitalize()
}
if !line_text.starts_with('- ') {
line_text = '- ' + line_text
}
return first_half + '\n' + line_text + second_half
}
fn delete_processed_line_from_log(line string) ! {
text := os.read_file(log_txt)!
new_text := text.replace_once(line, '')
os.write_file(log_txt, new_text)!
}
const db_strings = [
'db:',
'db.sqlite',
'db.mysql',
'db.pg',
'pg:',
]
const parser_strings = [
'parser:',
'ast:',
]
const stdlib_strings = [
'gg:',
'json:',
'json2:',
'time:',
'sync:',
'datatypes:',
'math:',
'math.',
'math.big',
'crypto',
'sokol',
'os:',
'rand:',
'math:',
'toml:',
'vlib:',
'arrays:',
'os.',
'term:',
'sync.',
'builtin:',
'builtin,',
'strconv',
'readline',
'cli:',
'eventbus:',
'encoding.',
'bitfield:',
'io:',
'io.',
'log:',
'flag:',
'regex:',
'tmpl:',
'hash:',
'stbi:',
]
fn is_stdlib(text string) bool {
return is_xxx(text, stdlib_strings)
}
fn is_db(text string) bool {
return is_xxx(text, db_strings)
}
const orm_strings = [
'orm:',
]
fn is_orm(text string) bool {
return is_xxx(text, orm_strings)
}
const cgen_strings = [
'cgen:',
'cgen,',
'v.gen.c:',
]
fn is_cgen(text string) bool {
return is_xxx(text, cgen_strings)
}
const js_backend_strings = [
'js:',
'v.gen.js:',
'jsgen:',
]
fn is_js_backend(text string) bool {
return is_xxx(text, js_backend_strings)
}
const internal_strings = [
'scanner:',
'transformer:',
'markused:',
'builder:',
'pref:',
'v.util',
]
fn is_internal(text string) bool {
return is_xxx(text, internal_strings)
}
const improvements_strings = [
'all:',
'v:',
'coroutines:',
'autofree',
]
fn is_improvements(text string) bool {
return is_xxx(text, improvements_strings)
}
const examples_strings = [
'example',
]
const skip_strings = [
'tests',
'readme:',
'.md:',
'typos',
' typo',
'cleanup',
'clean up',
]
fn is_examples(text string) bool {
return is_xxx(text, examples_strings)
}
fn is_skip(text string) bool {
return is_xxx(text, skip_strings)
}
const tools_strings = [
'tools:',
'vpm:',
'ci:',
'github:',
'gitignore',
'benchmark',
'v.help:',
'vtest',
'repl',
'REPL',
'vet',
'tools.',
]
fn is_tools(text string) bool {
return is_xxx(text, tools_strings)
}
fn is_parser(text string) bool {
return is_xxx(text, parser_strings)
}
const web_strings = [
'vweb',
'veb',
'x.vweb',
'websocket:',
'pico',
'x.sessions',
'picoev:',
'mbedtls',
'net:',
'net.',
'wasm:',
'http:',
]
fn is_web(text string) bool {
return is_xxx(text, web_strings)
}
const native_strings = [
'native:',
]
fn is_native(text string) bool {
return is_xxx(text, native_strings)
}
const vfmt_strings = [
'vfmt:',
'fmt:',
]
fn is_vfmt(text string) bool {
return is_xxx(text, vfmt_strings)
}
const os_support_strings = [
'FreeBSD',
'freebsd',
'OpenBSD',
'openbsd',
'macOS',
'macos',
'Windows',
'windows',
'Linux',
'linux',
]
fn is_os_support(text string) bool {
return is_xxx(text, os_support_strings)
}
fn is_comptime(text string) bool {
return text.contains('comptime:')
}
fn is_xxx(text string, words []string) bool {
for s in words {
if text.contains(s) {
return true
}
}
return false
}
fn print_category_hint() {
$for val in Category.values {
println('${int(val.value) + 1} - ${val.name}; ')
}
}