codewalker

This commit is contained in:
2025-11-24 05:48:13 +01:00
parent 4402cba8ac
commit d282a5dc95
29 changed files with 1412 additions and 453 deletions

View File

@@ -1,64 +0,0 @@
# CodeWalker Module
The CodeWalker module provides functionality to walk through directories and create a map of files with their content. It's particularly useful for processing code directories while respecting gitignore patterns.
## Features
- Walk through directories recursively
- Respect gitignore patterns to exclude files
- Store file content in memory
- Export files back to a directory structure
## Usage
```v
import incubaid.herolib.lib.lang.codewalker
mut cw := codewalker.new('/tmp/adir')!
// Get content of a specific file
content := cw.filemap.get('path/to/file.txt')!
// return output again
cw.filemap.content()
// Export all files to a destination directory
cw.filemap.export('/tmp/exported_files')!
```
### format of filemap
## full files
```
text before will be ignored
===FILE:filename===
code
===FILE:filename===
code
===END===
text behind will be ignored
```
## files with changes
```
text before will be ignored
===FILECHANGE:filename===
code
===FILECHANGE:filename===
code
===END===
text behind will be ignored
```
FILECHANGE and FILE can be mixed, in FILE it means we have full content otherwise only changed content e.g. a method or s struct and then we need to use morph to change it

View File

@@ -1,219 +0,0 @@
module codewalker
import incubaid.herolib.core.pathlib
pub struct CodeWalker {
pub mut:
ignorematcher IgnoreMatcher
errors []CWError
}
@[params]
pub struct FileMapArgs {
pub mut:
path string
content string
content_read bool = true // if we start from path, and this is on false then we don't read the content
}
// Public factory to parse the filemap-text format directly
pub fn (mut cw CodeWalker) parse(content string) !FileMap {
return cw.filemap_get_from_content(content)
}
pub fn (mut cw CodeWalker) filemap_get(args FileMapArgs) !FileMap {
if args.path != '' {
return cw.filemap_get_from_path(args.path, args.content_read)!
} else if args.content != '' {
return cw.filemap_get_from_content(args.content)!
} else {
return error('Either path or content must be provided to get FileMap')
}
}
// get the filemap from a path
fn (mut cw CodeWalker) filemap_get_from_path(path string, content_read bool) !FileMap {
mut dir := pathlib.get(path)
if !dir.exists() || !dir.is_dir() {
return error('Source directory "${path}" does not exist')
}
mut files := dir.list(ignore_default: false)!
mut fm := FileMap{
source: path
}
// collect ignore patterns from .gitignore and .heroignore files (recursively),
// and scope them to the directory where they were found
for mut p in files.paths {
if p.is_file() {
name := p.name()
if name == '.gitignore' || name == '.heroignore' {
content := p.read() or { '' }
if content != '' {
rel := p.path_relative(path) or { '' }
base_rel := if rel.contains('/') { rel.all_before_last('/') } else { '' }
cw.ignorematcher.add_content_with_base(base_rel, content)
}
}
}
}
for mut file in files.paths {
if file.is_file() {
name := file.name()
if name == '.gitignore' || name == '.heroignore' {
continue
}
relpath := file.path_relative(path)!
if cw.ignorematcher.is_ignored(relpath) {
continue
}
if content_read {
content := file.read()!
fm.content[relpath] = content
} else {
fm.content[relpath] = ''
}
}
}
return fm
}
// Parse a header line and return (kind, filename)
// kind: 'FILE' | 'FILECHANGE' | 'LEGACY' | 'END'
fn (mut cw CodeWalker) parse_header(line string, linenr int) !(string, string) {
if line == '===END===' {
return 'END', ''
}
if line.starts_with('===FILE:') && line.ends_with('===') {
name := line.trim_left('=').trim_right('=').all_after(':').trim_space()
if name.len < 1 {
cw.error('Invalid filename, < 1 chars.', linenr, 'filename_get', true)!
}
return 'FILE', name
}
if line.starts_with('===FILECHANGE:') && line.ends_with('===') {
name := line.trim_left('=').trim_right('=').all_after(':').trim_space()
if name.len < 1 {
cw.error('Invalid filename, < 1 chars.', linenr, 'filename_get', true)!
}
return 'FILECHANGE', name
}
// Legacy header: ===filename===
if line.starts_with('===') && line.ends_with('===') {
name := line.trim('=').trim_space()
if name == 'END' {
return 'END', ''
}
if name.len < 1 {
cw.error('Invalid filename, < 1 chars.', linenr, 'filename_get', true)!
}
return 'LEGACY', name
}
return '', ''
}
fn (mut cw CodeWalker) error(msg string, linenr int, category string, fail bool) ! {
cw.errors << CWError{
message: msg
linenr: linenr
category: category
}
if fail {
return error(msg)
}
}
// internal function to get the filename
fn (mut cw CodeWalker) parse_filename_get(line string, linenr int) !string {
parts := line.split('===')
if parts.len < 2 {
cw.error('Invalid filename line: ${line}.', linenr, 'filename_get', true)!
}
mut name := parts[1].trim_space()
if name.len < 2 {
cw.error('Invalid filename, < 2 chars: ${name}.', linenr, 'filename_get', true)!
}
return name
}
enum ParseState {
start
in_block
}
// Parse filemap content string
fn (mut cw CodeWalker) filemap_get_from_content(content string) !FileMap {
mut fm := FileMap{}
mut current_kind := '' // 'FILE' | 'FILECHANGE' | 'LEGACY'
mut filename := ''
mut block := []string{}
mut had_any_block := false
mut linenr := 0
for line in content.split_into_lines() {
linenr += 1
line2 := line.trim_space()
kind, name := cw.parse_header(line2, linenr)!
if kind == 'END' {
if filename == '' {
if had_any_block {
cw.error("Filename 'END' is reserved.", linenr, 'parse', true)!
} else {
cw.error('END found at start, not good.', linenr, 'parse', true)!
}
} else {
if current_kind == 'FILE' || current_kind == 'LEGACY' {
fm.content[filename] = block.join_lines()
} else if current_kind == 'FILECHANGE' {
fm.content_change[filename] = block.join_lines()
}
filename = ''
block = []string{}
current_kind = ''
}
continue
}
if kind in ['FILE', 'FILECHANGE', 'LEGACY'] {
// starting a new block header
if filename != '' {
if current_kind == 'FILE' || current_kind == 'LEGACY' {
fm.content[filename] = block.join_lines()
} else if current_kind == 'FILECHANGE' {
fm.content_change[filename] = block.join_lines()
}
}
filename = name
current_kind = kind
block = []string{}
had_any_block = true
continue
}
// Non-header line
if filename == '' {
if line2.len > 0 {
cw.error("Unexpected content before first file block: '${line}'.", linenr,
'parse', false)!
}
} else {
block << line
}
}
// EOF: flush current block if any
if filename != '' {
if current_kind == 'FILE' || current_kind == 'LEGACY' {
fm.content[filename] = block.join_lines()
} else if current_kind == 'FILECHANGE' {
fm.content_change[filename] = block.join_lines()
}
}
return fm
}

View File

@@ -1,253 +0,0 @@
module codewalker
import os
import incubaid.herolib.core.pathlib
fn test_parse_basic() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\nline1\nline2\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 1
assert fm.content['file1.txt'] == 'line1\nline2'
}
fn test_parse_multiple_files() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\nline1\n===FILE:file2.txt===\nlineA\nlineB\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 2
assert fm.content['file1.txt'] == 'line1'
assert fm.content['file2.txt'] == 'lineA\nlineB'
}
fn test_parse_empty_file_block() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:empty.txt===\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 1
assert fm.content['empty.txt'] == ''
}
fn test_parse_consecutive_end_and_file() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\ncontent1\n===END===\n===FILE:file2.txt===\ncontent2\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 2
assert fm.content['file1.txt'] == 'content1'
assert fm.content['file2.txt'] == 'content2'
}
fn test_parse_content_before_first_file_block() {
mut cw := new(CodeWalkerArgs{})!
test_content := 'unexpected content\n===FILE:file1.txt===\ncontent\n===END==='
// This should ideally log an error but still parse the file
fm := cw.parse(test_content)!
assert fm.content.len == 1
assert fm.content['file1.txt'] == 'content'
assert cw.errors.len > 0
assert cw.errors[0].message.contains('Unexpected content before first file block')
}
fn test_parse_content_after_end() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\ncontent\n===END===\nmore unexpected content'
// Implementation chooses to ignore content after END but return parsed content
fm := cw.parse(test_content)!
assert fm.content.len == 1
assert fm.content['file1.txt'] == 'content'
}
fn test_parse_invalid_filename_line() {
mut cw := new(CodeWalkerArgs{})!
test_content := '======\ncontent\n===END==='
cw.parse(test_content) or {
assert err.msg().contains('Invalid filename, < 1 chars')
return
}
assert false // Should have errored
}
fn test_parse_file_ending_without_end() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\nline1\nline2'
fm := cw.parse(test_content)!
assert fm.content.len == 1
assert fm.content['file1.txt'] == 'line1\nline2'
}
fn test_parse_empty_content() {
mut cw := new(CodeWalkerArgs{})!
test_content := ''
fm := cw.parse(test_content)!
assert fm.content.len == 0
}
fn test_parse_only_end_at_start() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===END==='
cw.parse(test_content) or {
assert err.msg().contains('END found at start, not good.')
return
}
assert false // Should have errored
}
fn test_parse_mixed_file_and_filechange() {
mut cw2 := new(CodeWalkerArgs{})!
test_content2 := '===FILE:file.txt===\nfull\n===FILECHANGE:file.txt===\npartial\n===END==='
fm2 := cw2.parse(test_content2)!
assert fm2.content.len == 1
assert fm2.content_change.len == 1
assert fm2.content['file.txt'] == 'full'
assert fm2.content_change['file.txt'] == 'partial'
}
fn test_parse_empty_block_between_files() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\ncontent1\n===FILE:file2.txt===\n===END===\n===FILE:file3.txt===\ncontent3\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 3
assert fm.content['file1.txt'] == 'content1'
assert fm.content['file2.txt'] == ''
assert fm.content['file3.txt'] == 'content3'
}
fn test_parse_multiple_empty_blocks() {
mut cw := new(CodeWalkerArgs{})!
test_content := '===FILE:file1.txt===\n===END===\n===FILE:file2.txt===\n===END===\n===FILE:file3.txt===\ncontent3\n===END==='
fm := cw.parse(test_content)!
assert fm.content.len == 3
assert fm.content['file1.txt'] == ''
assert fm.content['file2.txt'] == ''
assert fm.content['file3.txt'] == 'content3'
}
fn test_parse_filename_end_reserved() {
mut cw := new(CodeWalkerArgs{})!
// Legacy header 'END' used as filename should error when used as header for new block
test_content := '===file1.txt===\ncontent1\n===END===\n===END===\ncontent2\n===END==='
cw.parse(test_content) or {
assert err.msg().contains("Filename 'END' is reserved.")
return
}
assert false // Should have errored
}
fn test_filemap_export_and_write() ! {
// Setup temp dir
mut tmpdir := pathlib.get_dir(
path: os.join_path(os.temp_dir(), 'cw_test')
create: true
empty: true
)!
defer {
tmpdir.delete() or {}
}
// Build a FileMap
mut fm := FileMap{
source: tmpdir.path
}
fm.set('a/b.txt', 'hello')
fm.set('c.txt', 'world')
// Export to new dir
mut dest := pathlib.get_dir(
path: os.join_path(os.temp_dir(), 'cw_out')
create: true
empty: true
)!
defer {
dest.delete() or {}
}
fm.export(dest.path)!
mut f1 := pathlib.get_file(path: os.join_path(dest.path, 'a/b.txt'))!
mut f2 := pathlib.get_file(path: os.join_path(dest.path, 'c.txt'))!
assert f1.read()! == 'hello'
assert f2.read()! == 'world'
// Overwrite via write()
fm.set('a/b.txt', 'hello2')
fm.write(dest.path)!
assert f1.read()! == 'hello2'
}
fn test_filemap_content_roundtrip() {
mut fm := FileMap{}
fm.set('x.txt', 'X')
fm.content_change['y.txt'] = 'Y'
txt := fm.content()
assert txt.contains('===FILE:x.txt===')
assert txt.contains('===FILECHANGE:y.txt===')
assert txt.contains('===END===')
}
fn test_ignore_level_scoped() ! {
// create temp dir structure
mut root := pathlib.get_dir(
path: os.join_path(os.temp_dir(), 'cw_ign_lvl')
create: true
empty: true
)!
defer { root.delete() or {} }
// subdir with its own ignore
mut sub := pathlib.get_dir(path: os.join_path(root.path, 'sub'), create: true)!
mut hero := pathlib.get_file(path: os.join_path(sub.path, '.heroignore'), create: true)!
hero.write('dist/\n')!
// files under sub/dist should be ignored
mut dist := pathlib.get_dir(path: os.join_path(sub.path, 'dist'), create: true)!
mut a1 := pathlib.get_file(path: os.join_path(dist.path, 'a.txt'), create: true)!
a1.write('A')!
// sibling sub2 with a dist, should NOT be ignored by sub's .heroignore
mut sub2 := pathlib.get_dir(path: os.join_path(root.path, 'sub2'), create: true)!
mut dist2 := pathlib.get_dir(path: os.join_path(sub2.path, 'dist'), create: true)!
mut b1 := pathlib.get_file(path: os.join_path(dist2.path, 'b.txt'), create: true)!
b1.write('B')!
// a normal file under sub should be included
mut okf := pathlib.get_file(path: os.join_path(sub.path, 'ok.txt'), create: true)!
okf.write('OK')!
mut cw := new(CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: root.path)!
// sub/dist/a.txt should be ignored
assert 'sub/dist/a.txt' !in fm.content.keys()
// sub/ok.txt should be included
assert fm.content['sub/ok.txt'] == 'OK'
// sub2/dist/b.txt should be included (since .heroignore is level-scoped)
assert fm.content['sub2/dist/b.txt'] == 'B'
}
fn test_ignore_level_scoped_gitignore() ! {
mut root := pathlib.get_dir(
path: os.join_path(os.temp_dir(), 'cw_ign_git')
create: true
empty: true
)!
defer { root.delete() or {} }
// root has .gitignore ignoring logs/
mut g := pathlib.get_file(path: os.join_path(root.path, '.gitignore'), create: true)!
g.write('logs/\n')!
// nested structure
mut svc := pathlib.get_dir(path: os.join_path(root.path, 'svc'), create: true)!
// this logs/ should be ignored due to root .gitignore
mut logs := pathlib.get_dir(path: os.join_path(svc.path, 'logs'), create: true)!
mut out := pathlib.get_file(path: os.join_path(logs.path, 'out.txt'), create: true)!
out.write('ignored')!
// regular file should be included
mut appf := pathlib.get_file(path: os.join_path(svc.path, 'app.txt'), create: true)!
appf.write('app')!
mut cw := new(CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: root.path)!
assert 'svc/logs/out.txt' !in fm.content.keys()
assert fm.content['svc/app.txt'] == 'app'
}
fn test_parse_filename_end_reserved_legacy() {
mut cw := new(CodeWalkerArgs{})!
// Legacy header 'END' used as filename should error when used as header for new block
test_content := '===file1.txt===\ncontent1\n===END===\n===END===\ncontent2\n===END==='
cw.parse(test_content) or {
assert err.msg().contains("Filename 'END' is reserved.")
return
}
assert false // Should have errored
}

View File

@@ -1,12 +0,0 @@
module codewalker
@[params]
pub struct CodeWalkerArgs {
// No fields required for now; kept for API stability
}
pub fn new(args CodeWalkerArgs) !CodeWalker {
mut cw := CodeWalker{}
cw.ignorematcher = gitignore_matcher_new()
return cw
}

View File

@@ -1,75 +0,0 @@
module codewalker
import incubaid.herolib.core.pathlib
pub struct FileMap {
pub mut:
source string
content map[string]string
content_change map[string]string
errors []FMError
}
pub fn (mut fm FileMap) content() string {
mut out := []string{}
for filepath, filecontent in fm.content {
out << '===FILE:${filepath}==='
out << filecontent
}
for filepath, filecontent in fm.content_change {
out << '===FILECHANGE:${filepath}==='
out << filecontent
}
out << '===END==='
return out.join_lines()
}
// write in new location, all will be overwritten, will only work with full files, not changes
pub fn (mut fm FileMap) export(path string) ! {
for filepath, filecontent in fm.content {
dest := '${path}/${filepath}'
mut filepathtowrite := pathlib.get_file(path: dest, create: true)!
filepathtowrite.write(filecontent)!
}
}
@[PARAMS]
pub struct WriteParams {
path string
v_test bool = true
v_format bool = true
python_test bool
}
// update the files as found in the folder and update them or create
pub fn (mut fm FileMap) write(path string) ! {
for filepath, filecontent in fm.content {
dest := '${path}/${filepath}'
// In future: validate language-specific formatting/tests before overwrite
mut filepathtowrite := pathlib.get_file(path: dest, create: true)!
filepathtowrite.write(filecontent)!
}
// TODO: phase 2, work with morphe to integrate change in the file
}
pub fn (fm FileMap) get(relpath string) !string {
return fm.content[relpath] or { return error('File not found: ${relpath}') }
}
pub fn (mut fm FileMap) set(relpath string, content string) {
fm.content[relpath] = content
}
pub fn (mut fm FileMap) delete(relpath string) {
fm.content.delete(relpath)
}
pub fn (fm FileMap) find(path string) []string {
mut result := []string{}
for filepath, _ in fm.content {
if filepath.starts_with(path) {
result << filepath
}
}
return result
}

View File

@@ -1,161 +0,0 @@
module codewalker
// A minimal gitignore-like matcher used by CodeWalker
// Supports:
// - Directory patterns ending with '/': ignores any path that has this segment prefix
// - Extension patterns like '*.pyc' or '*.<ext>'
// - Simple substrings and '*' wildcards
// - Lines starting with '#' are comments; empty lines ignored
// No negation support for simplicity
const default_gitignore = '
.git/
.svn/
.hg/
.bzr/
node_modules/
__pycache__/
*.py[cod]
*.so
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.env
.venv
venv/
.tox/
.nox/
.coverage
.coveragerc
coverage.xml
*.cover
*.gem
*.pyc
.cache
.pytest_cache/
.mypy_cache/
.hypothesis/
.DS_Store
Thumbs.db
*.tmp
*.temp
*.log
'
struct IgnoreRule {
base string // relative dir from source root where the ignore file lives ('' means global)
pattern string
}
pub struct IgnoreMatcher {
pub mut:
rules []IgnoreRule
}
pub fn gitignore_matcher_new() IgnoreMatcher {
mut m := IgnoreMatcher{}
m.add_content(default_gitignore)
return m
}
// Add raw .gitignore-style content as global (root-scoped) rules
pub fn (mut m IgnoreMatcher) add_content(content string) {
m.add_content_with_base('', content)
}
// Add raw .gitignore/.heroignore-style content scoped to base_rel
pub fn (mut m IgnoreMatcher) add_content_with_base(base_rel string, content string) {
mut base := base_rel.replace('\\', '/').trim('/').to_lower()
for raw_line in content.split_into_lines() {
mut line := raw_line.trim_space()
if line.len == 0 || line.starts_with('#') {
continue
}
m.rules << IgnoreRule{
base: base
pattern: line
}
}
}
// Very simple glob/substring-based matching with directory scoping
pub fn (m IgnoreMatcher) is_ignored(relpath string) bool {
mut path := relpath.replace('\\', '/').trim_left('/')
path_low := path.to_lower()
for rule in m.rules {
mut pat := rule.pattern.replace('\\', '/').trim_space()
if pat == '' {
continue
}
// Determine subpath relative to base
mut sub := path_low
if rule.base != '' {
base := rule.base
if sub == base {
// path equals the base dir; ignore rules apply to entries under base, not the base itself
continue
}
if sub.starts_with(base + '/') {
sub = sub[(base.len + 1)..]
} else {
continue // rule not applicable for this path
}
}
// Directory pattern (relative to base)
if pat.ends_with('/') {
mut dirpat := pat.trim_right('/')
dirpat = dirpat.trim_left('/').to_lower()
if sub == dirpat || sub.starts_with(dirpat + '/') || sub.contains('/' + dirpat + '/') {
return true
}
continue
}
// Extension pattern *.ext
if pat.starts_with('*.') {
ext := pat.all_after_last('.').to_lower()
if sub.ends_with('.' + ext) {
return true
}
continue
}
// Simple wildcard * anywhere -> sequential contains match
if pat.contains('*') {
mut parts := pat.to_lower().split('*')
mut idx := 0
mut ok := true
for part in parts {
if part == '' {
continue
}
pos := sub.index_after(part, idx) or { -1 }
if pos == -1 {
ok = false
break
}
idx = pos + part.len
}
if ok {
return true
}
continue
}
// Fallback: substring match (case-insensitive) on subpath
if sub.contains(pat.to_lower()) {
return true
}
}
return false
}

View File

@@ -1,16 +0,0 @@
module codewalker
pub struct CWError {
pub:
message string
linenr int
category string
}
pub struct FMError {
pub:
message string
linenr int // is optional
category string
filename string
}

View File

@@ -1,4 +1,4 @@
module codewalker
module heroprompt
import os
@@ -82,99 +82,6 @@ pub:
typ string
}
// list_directory lists the contents of a directory.
// - base_path: workspace base path
// - rel_path: relative path from base (or absolute path)
// Returns a list of DirItem with name and type (file/directory).
pub fn list_directory(base_path string, rel_path string) ![]DirItem {
dir := resolve_path(base_path, rel_path)
if dir.len == 0 {
return error('base_path not set')
}
entries := os.ls(dir) or { return error('cannot list directory') }
mut out := []DirItem{}
for e in entries {
full := os.join_path(dir, e)
if os.is_dir(full) {
out << DirItem{
name: e
typ: 'directory'
}
} else if os.is_file(full) {
out << DirItem{
name: e
typ: 'file'
}
}
}
return out
}
// list_directory_filtered lists the contents of a directory with ignore filtering applied.
// - base_path: workspace base path
// - rel_path: relative path from base (or absolute path)
// - ignore_matcher: IgnoreMatcher to filter out ignored files/directories
// Returns a list of DirItem with name and type (file/directory), filtered by ignore patterns.
pub fn list_directory_filtered(base_path string, rel_path string, ignore_matcher &IgnoreMatcher) ![]DirItem {
dir := resolve_path(base_path, rel_path)
if dir.len == 0 {
return error('base_path not set')
}
entries := os.ls(dir) or { return error('cannot list directory') }
mut out := []DirItem{}
for e in entries {
full := os.join_path(dir, e)
// Calculate relative path from base_path for ignore checking
mut check_path := if rel_path.len > 0 {
if rel_path.ends_with('/') { rel_path + e } else { rel_path + '/' + e }
} else {
e
}
// For directories, also check with trailing slash
is_directory := os.is_dir(full)
mut should_ignore := ignore_matcher.is_ignored(check_path)
if is_directory && !should_ignore {
// Also check directory pattern with trailing slash
should_ignore = ignore_matcher.is_ignored(check_path + '/')
}
// Check if this entry should be ignored
if should_ignore {
continue
}
if is_directory {
out << DirItem{
name: e
typ: 'directory'
}
} else if os.is_file(full) {
out << DirItem{
name: e
typ: 'file'
}
}
}
return out
}
// list_files_recursive recursively lists all files in a directory
pub fn list_files_recursive(root string) []string {
mut out := []string{}
entries := os.ls(root) or { return out }
for e in entries {
fp := os.join_path(root, e)
if os.is_dir(fp) {
out << list_files_recursive(fp)
} else if os.is_file(fp) {
out << fp
}
}
return out
}
// build_file_tree_fs builds a file system tree for given root directories
pub fn build_file_tree_fs(roots []string, prefix string) string {
mut out := ''