Added a filereader interface + FS and Mock implementations for easier testing of preprocessor.

This commit is contained in:
Mattias Hansson 2025-10-12 15:23:27 +02:00
parent f7f247b69c
commit 9578392f55
7 changed files with 348 additions and 0 deletions

View file

@ -0,0 +1,95 @@
package preproc
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
type FileReader interface {
ReadLines(includeSpec string, currentDir string) ([]string, error)
}
type DiskFileReader struct {
cache map[string][]string
libPath string
}
func NewDiskFileReader() *DiskFileReader {
return &DiskFileReader{
cache: make(map[string][]string),
libPath: os.Getenv("C65LIBPATH"),
}
}
func (d *DiskFileReader) ReadLines(includeSpec string, currentDir string) ([]string, error) {
path, err := d.resolvePath(includeSpec, currentDir)
if err != nil {
return nil, err
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
if lines, ok := d.cache[abs]; ok {
return lines, nil
}
data, err := os.ReadFile(abs)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
d.cache[abs] = lines
return lines, nil
}
func (d *DiskFileReader) resolvePath(spec string, curDir string) (string, error) {
if len(spec) == 0 {
return "", errors.New("empty include path")
}
// <file> -> library include
if strings.HasPrefix(spec, "<") && strings.HasSuffix(spec, ">") {
base := strings.TrimSpace(spec[1 : len(spec)-1])
if d.libPath == "" {
return "", errors.New("C65LIBPATH not set for angle-bracket include")
}
path := filepath.Join(d.libPath, base)
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("library include not found: %s", path)
}
return path, nil
}
// quoted or bare -> relative to current dir
base := strings.Trim(spec, `"'`)
path := filepath.Join(curDir, base)
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("include not found: %s", path)
}
return path, nil
}
type MockFileReader struct {
files map[string][]string
}
func NewMockFileReader(files map[string][]string) *MockFileReader {
return &MockFileReader{
files: files,
}
}
func (m *MockFileReader) ReadLines(includeSpec string, _ string) ([]string, error) {
lines, ok := m.files[includeSpec]
if !ok {
return nil, fmt.Errorf("open %s: no such file or directory", includeSpec)
}
return lines, nil
}

View file

@ -0,0 +1,2 @@
// library file
LABEL appsub

View file

@ -0,0 +1,2 @@
// library file
LABEL app

View file

@ -0,0 +1,2 @@
// library file
LABEL libsub

View file

@ -0,0 +1,2 @@
// library file
LABEL lib

View file

@ -0,0 +1,245 @@
package preproc
import (
"os"
"path/filepath"
"testing"
)
func TestDiskFileReader_ReadLines_LibraryIncludes(t *testing.T) {
origPath := os.Getenv("C65LIBPATH")
defer os.Setenv("C65LIBPATH", origPath)
libPath, _ := filepath.Abs("filereader_mocks/lib")
os.Setenv("C65LIBPATH", libPath)
// Now create reader
reader := NewDiskFileReader()
tests := []struct {
name string
includeSpec string
wantErr bool
}{
{
name: "simple library include",
includeSpec: "<test_lib.c65>",
wantErr: false,
},
{
name: "nested library include",
includeSpec: "<libsub/test_libsub.c65>",
wantErr: false,
},
{
name: "missing library file",
includeSpec: "<notfound.c65>",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lines, err := reader.ReadLines(tt.includeSpec, "")
if (err != nil) != tt.wantErr {
t.Errorf("ReadLines() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(lines) == 0 {
t.Error("expected lines, got empty slice")
}
})
}
}
func TestDiskFileReader_ReadLines_LibraryWithoutEnv(t *testing.T) {
origPath := os.Getenv("C65LIBPATH")
defer os.Setenv("C65LIBPATH", origPath)
os.Setenv("C65LIBPATH", "")
// Now create reader
reader := NewDiskFileReader()
_, err := reader.ReadLines("<test_lib.c65>", "")
if err == nil {
t.Error("expected error when C65LIBPATH not set")
}
}
func TestDiskFileReader_ReadLines_RelativeIncludes(t *testing.T) {
reader := NewDiskFileReader()
appDir, _ := filepath.Abs("filereader_mocks/app")
tests := []struct {
name string
includeSpec string
currentDir string
wantErr bool
}{
{
name: "bare include",
includeSpec: "test_app.c65",
currentDir: appDir,
wantErr: false,
},
{
name: "quoted include",
includeSpec: `"test_app.c65"`,
currentDir: appDir,
wantErr: false,
},
{
name: "single quoted include",
includeSpec: "'test_app.c65'",
currentDir: appDir,
wantErr: false,
},
{
name: "nested relative include",
includeSpec: "appsub/test_appsub.c65",
currentDir: appDir,
wantErr: false,
},
{
name: "missing file",
includeSpec: "notfound.c65",
currentDir: appDir,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lines, err := reader.ReadLines(tt.includeSpec, tt.currentDir)
if (err != nil) != tt.wantErr {
t.Errorf("ReadLines() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(lines) == 0 {
t.Error("expected lines, got empty slice")
}
})
}
}
func TestDiskFileReader_ReadLines_Cache(t *testing.T) {
reader := NewDiskFileReader()
appDir, _ := filepath.Abs("filereader_mocks/app")
// First read
lines1, err := reader.ReadLines("test_app.c65", appDir)
if err != nil {
t.Fatalf("first read failed: %v", err)
}
// Second read (should hit cache)
lines2, err := reader.ReadLines("test_app.c65", appDir)
if err != nil {
t.Fatalf("second read failed: %v", err)
}
// Verify same slice (from cache)
if len(lines1) != len(lines2) {
t.Errorf("cache returned different length: %d vs %d", len(lines1), len(lines2))
}
// Verify cache was actually used (check map size)
if len(reader.cache) != 1 {
t.Errorf("expected 1 cache entry, got %d", len(reader.cache))
}
}
func TestDiskFileReader_ReadLines_EmptyFile(t *testing.T) {
reader := NewDiskFileReader()
appDir, _ := filepath.Abs("filereader_mocks/app")
lines, err := reader.ReadLines("empty.c65", appDir)
if err != nil {
t.Fatalf("failed to read empty file: %v", err)
}
// Empty file still returns slice with one empty string
if len(lines) != 1 {
t.Errorf("empty file: expected 1 line, got %d", len(lines))
}
}
func TestDiskFileReader_ReadLines_EmptySpec(t *testing.T) {
reader := NewDiskFileReader()
_, err := reader.ReadLines("", "")
if err == nil {
t.Error("expected error for empty include spec")
}
}
func TestMockFileReader_ReadLines(t *testing.T) {
files := map[string][]string{
"test.c65": {"LDA #$00", "STA $D020"},
"empty": {},
}
reader := NewMockFileReader(files)
lines, err := reader.ReadLines("test.c65", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lines) != 2 {
t.Errorf("expected 2 lines, got %d", len(lines))
}
_, err = reader.ReadLines("notfound", "")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestMockFileReader_LibraryInclude(t *testing.T) {
files := map[string][]string{
"<test_lib.c65>": {"LDA #$00", "STA $D020"},
}
reader := NewMockFileReader(files)
lines, err := reader.ReadLines("<test_lib.c65>", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lines) != 2 {
t.Errorf("expected 2 lines, got %d", len(lines))
}
}
func TestMockFileReader_NormalInclude(t *testing.T) {
files := map[string][]string{
"test_app.c65": {"JMP $1000"},
}
reader := NewMockFileReader(files)
lines, err := reader.ReadLines("test_app.c65", "some/dir")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lines) != 1 {
t.Errorf("expected 1 line, got %d", len(lines))
}
}
func TestMockFileReader_MissingFile(t *testing.T) {
files := map[string][]string{
"exists.c65": {"NOP"},
}
reader := NewMockFileReader(files)
_, err := reader.ReadLines("notfound.c65", "")
if err == nil {
t.Error("expected error for missing file")
}
}