Add build graph preview functionality and enhance configuration parsing

- Introduced GraphNodeSummary and GraphPlanSummary structures for build graph representation.
- Implemented preview_build_graph function to generate a summary of the build graph.
- Refactored configuration parsing to improve handling of boolean values and inheritance of lists.
- Added unit tests for build graph preview and configuration parsing.
I still don't know how to write tests.
main
Joca 2025-11-22 22:10:32 -03:00
parent d89a53de94
commit 4347a882a2
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
8 changed files with 405 additions and 99 deletions

View File

@ -59,6 +59,57 @@ struct BuildGraph {
unresolved map[string][]string
}
pub struct GraphNodeSummary {
pub:
id string
name string
target BuildTarget
dependencies []string
raw_dependencies []string
is_directive bool
output_path string
}
pub struct GraphPlanSummary {
pub:
nodes []GraphNodeSummary
order []string
unresolved map[string][]string
}
pub fn preview_build_graph(build_config &config.BuildConfig) !GraphPlanSummary {
graph := plan_build_graph(build_config)!
mut nodes := []GraphNodeSummary{}
for node in graph.nodes {
nodes << GraphNodeSummary{
id: node.id
name: node.name
target: node.target
dependencies: node.dependencies.clone()
raw_dependencies: node.raw_dependencies.clone()
is_directive: node.is_directive
output_path: node.output_path
}
}
mut order := []string{}
for idx in graph.order {
order << graph.nodes[idx].id
}
mut unresolved := map[string][]string{}
for id, deps in graph.unresolved {
unresolved[id] = deps.clone()
}
return GraphPlanSummary{
nodes: nodes
order: order
unresolved: unresolved
}
}
struct DirectiveBuildContext {
source string
object string
@ -653,6 +704,37 @@ fn auto_discover_sources(mut build_config config.BuildConfig) {
}
}
}
// Ensure the main project tool exists if it wasn't explicitly defined
// This handles the case where config.ini defines other tools but not the main project tool
mut main_tool_exists := false
for tool in build_config.tools {
if tool.name == build_config.project_name {
main_tool_exists = true
break
}
}
if !main_tool_exists {
// Look for src/<project_name>.cpp
// Really shit way to do it but meh, we'll figure this out soon enough.
main_src := os.join_path(build_config.src_dir, '${build_config.project_name}.cpp')
if os.is_file(main_src) {
if build_config.verbose {
println('Auto-discovered main project tool: ${build_config.project_name} from ${main_src}')
}
new_tool := config.ToolConfig{
name: build_config.project_name
sources: [main_src]
debug: build_config.debug
optimize: build_config.optimize
verbose: build_config.verbose
libraries: [] // Will inherit global libs during link
}
build_config.tools << new_tool
}
}
}
pub fn clean(build_config config.BuildConfig) {

View File

@ -203,6 +203,30 @@ fn merge_unique(mut target []string, additions []string) {
}
}
fn resolve_bool(base bool, override_str string, scope string, field string, mut warnings []string) bool {
if override_str.trim_space() == '' {
return base
}
return parse_bool_str(override_str) or {
warnings << 'Invalid boolean value for ${scope} ${field}: ${override_str}'
base
}
}
fn inherit_list(primary []string, inherited []string) []string {
mut merged := primary.clone()
merge_unique(mut merged, inherited)
return merged
}
fn scope_label(name string, kind string) string {
trimmed := name.trim_space()
if trimmed != '' {
return '${kind} ${trimmed}'
}
return kind
}
fn normalize_raw_config(raw RawBuildConfig, mut warnings []string) BuildConfig {
mut cfg := default_config
default_shared := SharedLibConfig{}
@ -230,113 +254,49 @@ fn normalize_raw_config(raw RawBuildConfig, mut warnings []string) BuildConfig {
cfg.dependencies_dir = raw.global.dependencies_dir
}
if raw.global.debug_str != '' {
cfg.debug = parse_bool_str(raw.global.debug_str) or {
warnings << 'Invalid boolean value for global.debug: ${raw.global.debug_str}'
cfg.debug
}
}
if raw.global.optimize_str != '' {
cfg.optimize = parse_bool_str(raw.global.optimize_str) or {
warnings << 'Invalid boolean value for global.optimize: ${raw.global.optimize_str}'
cfg.optimize
}
}
if raw.global.verbose_str != '' {
cfg.verbose = parse_bool_str(raw.global.verbose_str) or {
warnings << 'Invalid boolean value for global.verbose: ${raw.global.verbose_str}'
cfg.verbose
}
}
if raw.global.parallel_str != '' {
cfg.parallel_compilation = parse_bool_str(raw.global.parallel_str) or {
warnings << 'Invalid boolean value for global.parallel_compilation: ${raw.global.parallel_str}'
cfg.parallel_compilation
}
}
cfg.debug = resolve_bool(cfg.debug, raw.global.debug_str, 'global', 'debug', mut warnings)
cfg.optimize = resolve_bool(cfg.optimize, raw.global.optimize_str, 'global', 'optimize', mut warnings)
cfg.verbose = resolve_bool(cfg.verbose, raw.global.verbose_str, 'global', 'verbose', mut warnings)
cfg.parallel_compilation = resolve_bool(cfg.parallel_compilation, raw.global.parallel_str, 'global', 'parallel_compilation', mut warnings)
cfg.include_dirs << raw.global.include_dirs
cfg.lib_search_paths << raw.global.lib_search_paths
cfg.libraries << raw.global.libraries
cfg.cflags << raw.global.cflags
cfg.ldflags << raw.global.ldflags
cfg.include_dirs = inherit_list(raw.global.include_dirs, cfg.include_dirs)
cfg.lib_search_paths = inherit_list(raw.global.lib_search_paths, cfg.lib_search_paths)
cfg.libraries = inherit_list(raw.global.libraries, cfg.libraries)
cfg.cflags = inherit_list(raw.global.cflags, cfg.cflags)
cfg.ldflags = inherit_list(raw.global.ldflags, cfg.ldflags)
for raw_lib in raw.shared_libs {
scope := scope_label(raw_lib.name, 'shared_lib')
mut lib := SharedLibConfig{
name: raw_lib.name
output_dir: if raw_lib.output_dir != '' { raw_lib.output_dir } else { default_shared.output_dir }
sources: raw_lib.sources.clone()
libraries: raw_lib.libraries.clone()
debug: cfg.debug
optimize: cfg.optimize
verbose: cfg.verbose
debug: resolve_bool(cfg.debug, raw_lib.debug_str, scope, 'debug', mut warnings)
optimize: resolve_bool(cfg.optimize, raw_lib.optimize_str, scope, 'optimize', mut warnings)
verbose: resolve_bool(cfg.verbose, raw_lib.verbose_str, scope, 'verbose', mut warnings)
}
lib.include_dirs = raw_lib.include_dirs.clone()
lib.cflags = raw_lib.cflags.clone()
lib.ldflags = raw_lib.ldflags.clone()
if raw_lib.debug_str != '' {
lib.debug = parse_bool_str(raw_lib.debug_str) or {
warnings << 'Invalid boolean value for shared_lib ${raw_lib.name} debug: ${raw_lib.debug_str}'
lib.debug
}
}
if raw_lib.optimize_str != '' {
lib.optimize = parse_bool_str(raw_lib.optimize_str) or {
warnings << 'Invalid boolean value for shared_lib ${raw_lib.name} optimize: ${raw_lib.optimize_str}'
lib.optimize
}
}
if raw_lib.verbose_str != '' {
lib.verbose = parse_bool_str(raw_lib.verbose_str) or {
warnings << 'Invalid boolean value for shared_lib ${raw_lib.name} verbose: ${raw_lib.verbose_str}'
lib.verbose
}
}
merge_unique(mut lib.include_dirs, cfg.include_dirs)
merge_unique(mut lib.cflags, cfg.cflags)
merge_unique(mut lib.ldflags, cfg.ldflags)
lib.include_dirs = inherit_list(raw_lib.include_dirs, cfg.include_dirs)
lib.cflags = inherit_list(raw_lib.cflags, cfg.cflags)
lib.ldflags = inherit_list(raw_lib.ldflags, cfg.ldflags)
cfg.shared_libs << lib
}
for raw_tool in raw.tools {
scope := scope_label(raw_tool.name, 'tool')
mut tool := ToolConfig{
name: raw_tool.name
output_dir: if raw_tool.output_dir != '' { raw_tool.output_dir } else { default_tool.output_dir }
sources: raw_tool.sources.clone()
libraries: raw_tool.libraries.clone()
debug: cfg.debug
optimize: cfg.optimize
verbose: cfg.verbose
debug: resolve_bool(cfg.debug, raw_tool.debug_str, scope, 'debug', mut warnings)
optimize: resolve_bool(cfg.optimize, raw_tool.optimize_str, scope, 'optimize', mut warnings)
verbose: resolve_bool(cfg.verbose, raw_tool.verbose_str, scope, 'verbose', mut warnings)
}
tool.include_dirs = raw_tool.include_dirs.clone()
tool.cflags = raw_tool.cflags.clone()
tool.ldflags = raw_tool.ldflags.clone()
if raw_tool.debug_str != '' {
tool.debug = parse_bool_str(raw_tool.debug_str) or {
warnings << 'Invalid boolean value for tool ${raw_tool.name} debug: ${raw_tool.debug_str}'
tool.debug
}
}
if raw_tool.optimize_str != '' {
tool.optimize = parse_bool_str(raw_tool.optimize_str) or {
warnings << 'Invalid boolean value for tool ${raw_tool.name} optimize: ${raw_tool.optimize_str}'
tool.optimize
}
}
if raw_tool.verbose_str != '' {
tool.verbose = parse_bool_str(raw_tool.verbose_str) or {
warnings << 'Invalid boolean value for tool ${raw_tool.name} verbose: ${raw_tool.verbose_str}'
tool.verbose
}
}
merge_unique(mut tool.include_dirs, cfg.include_dirs)
merge_unique(mut tool.cflags, cfg.cflags)
merge_unique(mut tool.ldflags, cfg.ldflags)
tool.include_dirs = inherit_list(raw_tool.include_dirs, cfg.include_dirs)
tool.cflags = inherit_list(raw_tool.cflags, cfg.cflags)
tool.ldflags = inherit_list(raw_tool.ldflags, cfg.ldflags)
cfg.tools << tool
}

20
deps/deps.v vendored
View File

@ -23,19 +23,24 @@ pub fn extract_dependencies(source_file string) ![]string {
in_string = false
current_string_char = rune(0)
} else if !in_string {
if c == `#` && i + 1 < content.len && content[i + 1] == `i` {
// Found #include
i += 7 // skip "#include"
if c == `#` && i + 8 <= content.len && content[i..].starts_with('#include') {
i += '#include'.len
for i < content.len && content[i].is_space() {
i++
}
if i < content.len && (content[i] == `"` || content[i] == `<`) {
mut quote_char := content[i]
if i < content.len && (content[i] == `"` || content[i] == `<` || content[i] == `'`) {
opening := content[i]
closing := match opening {
`"` { `"` }
`'` { `'` }
`<` { `>` }
else { opening }
}
i++
mut include_path := []u8{}
for i < content.len && content[i] != quote_char {
for i < content.len && content[i] != closing {
include_path << content[i]
i++
}
@ -43,11 +48,8 @@ pub fn extract_dependencies(source_file string) ![]string {
if include_path.len > 0 {
include_name := include_path.bytestr()
if include_name.contains('/') || include_name.contains('\\') {
// Relative path
dependencies << include_name
} else {
// System include - we could search standard paths
// but for now just add the name
dependencies << include_name
}
}

70
tests/build_graph_test.v Normal file
View File

@ -0,0 +1,70 @@
module tests
import os
import builder
import config
fn test_preview_build_graph_orders_dependencies() {
tmp := new_temp_dir('lana_graph')
defer {
os.rmdir_all(tmp) or {}
}
src_dir := os.join_path(tmp, 'src')
lib_dir := os.join_path(src_dir, 'lib')
tool_dir := os.join_path(src_dir, 'tools')
os.mkdir_all(lib_dir) or { panic(err) }
os.mkdir_all(tool_dir) or { panic(err) }
core_src := os.join_path(lib_dir, 'core.cpp')
tool_src := os.join_path(tool_dir, 'demo.cpp')
os.write_file(core_src, '// core stub\n') or { panic(err) }
os.write_file(tool_src, '// tool stub\n') or { panic(err) }
mut cfg := config.BuildConfig{
project_name: 'demo'
src_dir: src_dir
build_dir: os.join_path(tmp, 'build')
bin_dir: os.join_path(tmp, 'bin')
toolchain: 'gcc'
compiler: 'g++'
debug: true
shared_libs: [
config.SharedLibConfig{
name: 'core'
sources: [core_src]
}
]
tools: [
config.ToolConfig{
name: 'demo'
sources: [tool_src]
libraries: ['core']
}
]
}
summary := builder.preview_build_graph(&cfg) or { panic(err) }
assert summary.nodes.len == 2
assert summary.unresolved.len == 0
mut shared_found := false
mut tool_found := false
for node in summary.nodes {
if node.id == 'shared:core' {
shared_found = true
assert node.dependencies.len == 0
}
if node.id == 'tool:demo' {
tool_found = true
assert node.dependencies.contains('shared:core')
}
}
assert shared_found
assert tool_found
assert summary.order.len == 2
assert summary.order[0] == 'shared:core'
assert summary.order[1] == 'tool:demo'
}

89
tests/config_test.v Normal file
View File

@ -0,0 +1,89 @@
module tests
import os
import config
fn test_parse_config_file_reads_sections() {
tmp := new_temp_dir('lana_config')
defer {
os.rmdir_all(tmp) or {}
}
config_path := os.join_path(tmp, 'config.ini')
config_content := '[global]\nproject_name = sample\nsrc_dir = src\ndebug = false\noptimize = true\nparallel_compilation = false\ninclude_dirs = include, external/include\nlibraries = cli\ncflags = -Wall -Wextra\n\n[shared_libs]\nname = core\nsources = src/lib/core.cpp\ninclude_dirs = include\ncflags = -fPIC\nlibraries = \n\n[tools]\nname = sample\nsources = src/main.cpp\nlibraries = core\n'
os.write_file(config_path, config_content) or { panic(err) }
cfg := config.parse_config_file(config_path) or { panic(err) }
assert cfg.project_name == 'sample'
assert cfg.src_dir == 'src'
assert !cfg.debug
assert cfg.optimize
assert !cfg.parallel_compilation
assert cfg.include_dirs.len == 2
assert cfg.include_dirs.contains('include')
assert cfg.include_dirs.contains('external/include')
assert cfg.cflags.contains('-Wall')
assert cfg.cflags.contains('-Wextra')
assert cfg.libraries == ['cli']
assert cfg.shared_libs.len == 1
shared_cfg := cfg.shared_libs[0]
assert shared_cfg.name == 'core'
assert shared_cfg.include_dirs.contains('include')
assert shared_cfg.cflags.contains('-fPIC')
assert cfg.tools.len == 1
tool := cfg.tools[0]
assert tool.name == 'sample'
assert tool.libraries == ['core']
}
fn test_parse_build_directives_extracts_units() {
tmp := new_temp_dir('lana_directives')
defer {
os.rmdir_all(tmp) or {}
}
src_dir := os.join_path(tmp, 'src')
tool_dir := os.join_path(src_dir, 'tools')
lib_dir := os.join_path(src_dir, 'lib')
os.mkdir_all(tool_dir) or { panic(err) }
os.mkdir_all(lib_dir) or { panic(err) }
tool_source := '// build-directive: unit-name(tools/example)\n// build-directive: depends-units(lib/cli)\n// build-directive: link(cli.so)\n// build-directive: out(tools/example)\n// build-directive: cflags(-DTEST)\n// build-directive: ldflags(-pthread)\n// build-directive: shared(false)\n'
os.write_file(os.join_path(tool_dir, 'example.cpp'), tool_source) or { panic(err) }
shared_source := '// build-directive: unit-name(lib/cli)\n// build-directive: depends-units()\n// build-directive: link()\n// build-directive: out(lib/cli)\n// build-directive: shared(true)\n'
os.write_file(os.join_path(lib_dir, 'cli.cpp'), shared_source) or { panic(err) }
mut cfg := config.BuildConfig{
project_name: 'sample'
src_dir: src_dir
build_dir: os.join_path(tmp, 'build')
bin_dir: os.join_path(tmp, 'bin')
}
cfg.parse_build_directives() or { panic(err) }
assert cfg.build_directives.len == 2
mut tool_found := false
mut shared_found := false
for directive in cfg.build_directives {
if directive.unit_name == 'tools/example' {
tool_found = true
assert directive.depends_units == ['lib/cli']
assert directive.link_libs == ['cli.so']
assert directive.output_path == 'tools/example'
assert directive.cflags.contains('-DTEST')
assert directive.ldflags.contains('-pthread')
assert !directive.is_shared
}
if directive.unit_name == 'lib/cli' {
shared_found = true
assert directive.is_shared
}
}
assert tool_found
assert shared_found
}

45
tests/deps_test.v Normal file
View File

@ -0,0 +1,45 @@
module tests
import os
import deps
import config
fn test_extract_dependencies_finds_includes() {
tmp := new_temp_dir('lana_deps')
defer {
os.rmdir_all(tmp) or {}
}
source_path := os.join_path(tmp, 'sample.cpp')
source_content := '#include "foo/bar.h"\n#include <vector>\nint main() { return 0; }\n'
os.write_file(source_path, source_content) or { panic(err) }
parsed := deps.extract_dependencies(source_path) or { panic(err) }
assert parsed.any(it.contains('foo/bar.h'))
assert parsed.any(it.contains('vector'))
}
fn test_fetch_dependencies_runs_build_commands() {
tmp := new_temp_dir('lana_fetch')
defer {
os.rmdir_all(tmp) or {}
}
dep_root := os.join_path(tmp, 'deps')
mut cfg := config.BuildConfig{
dependencies_dir: dep_root
dependencies: [
config.Dependency{
name: 'local'
extract_to: 'localpkg'
build_cmds: ['touch built.txt']
}
]
}
deps.fetch_dependencies(cfg) or { panic(err) }
assert os.is_dir(os.join_path(dep_root, 'localpkg'))
assert os.is_file(os.join_path(dep_root, 'localpkg', 'built.txt'))
}

47
tests/discovery_test.v Normal file
View File

@ -0,0 +1,47 @@
module tests
import os
import builder
import config
fn test_auto_discover_main_tool_when_other_tools_exist() {
tmp := new_temp_dir('lana_discovery')
defer {
os.rmdir_all(tmp) or {}
}
src_dir := os.join_path(tmp, 'src')
tools_dir := os.join_path(src_dir, 'tools')
os.mkdir_all(tools_dir) or { panic(err) }
// Create a tool that IS in config
os.write_file(os.join_path(tools_dir, 'existing.cpp'), 'int main(){}') or { panic(err) }
// Create a main source that SHOULD be discovered as the main tool
os.write_file(os.join_path(src_dir, 'testproj.cpp'), 'int main(){}') or { panic(err) }
mut cfg := config.BuildConfig{
project_name: 'testproj'
src_dir: src_dir
tools: [
config.ToolConfig{
name: 'existing'
sources: [os.join_path(tools_dir, 'existing.cpp')]
}
]
}
// Run auto-discovery
builder.auto_discover_sources(mut cfg)
// Check if testproj tool was added
mut found := false
for tool in cfg.tools {
if tool.name == 'testproj' {
found = true
assert tool.sources.len == 1
assert tool.sources[0].ends_with('testproj.cpp')
}
}
assert found
}

11
tests/test_helpers.v Normal file
View File

@ -0,0 +1,11 @@
module tests
import os
import rand
fn new_temp_dir(prefix string) string {
id := rand.uuid_v4()
path := os.join_path(os.temp_dir(), '${prefix}_${id}')
os.mkdir_all(path) or { panic(err) }
return path
}