From 9578392f551c1d0306600d4afbe6bf3d0f43edea Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Sun, 12 Oct 2025 15:23:27 +0200 Subject: [PATCH] Added a filereader interface + FS and Mock implementations for easier testing of preprocessor. --- internal/preproc/filereader.go | 95 +++++++ .../app/appsub/test_appsub.c65 | 2 + .../preproc/filereader_mocks/app/empty.c65 | 0 .../preproc/filereader_mocks/app/test_app.c65 | 2 + .../lib/libsub/test_libsub.c65 | 2 + .../preproc/filereader_mocks/lib/test_lib.c65 | 2 + internal/preproc/filereader_test.go | 245 ++++++++++++++++++ 7 files changed, 348 insertions(+) create mode 100644 internal/preproc/filereader.go create mode 100644 internal/preproc/filereader_mocks/app/appsub/test_appsub.c65 create mode 100644 internal/preproc/filereader_mocks/app/empty.c65 create mode 100644 internal/preproc/filereader_mocks/app/test_app.c65 create mode 100644 internal/preproc/filereader_mocks/lib/libsub/test_libsub.c65 create mode 100644 internal/preproc/filereader_mocks/lib/test_lib.c65 create mode 100644 internal/preproc/filereader_test.go diff --git a/internal/preproc/filereader.go b/internal/preproc/filereader.go new file mode 100644 index 0000000..b5cfc6a --- /dev/null +++ b/internal/preproc/filereader.go @@ -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") + } + + // -> 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 +} diff --git a/internal/preproc/filereader_mocks/app/appsub/test_appsub.c65 b/internal/preproc/filereader_mocks/app/appsub/test_appsub.c65 new file mode 100644 index 0000000..c857044 --- /dev/null +++ b/internal/preproc/filereader_mocks/app/appsub/test_appsub.c65 @@ -0,0 +1,2 @@ +// library file +LABEL appsub \ No newline at end of file diff --git a/internal/preproc/filereader_mocks/app/empty.c65 b/internal/preproc/filereader_mocks/app/empty.c65 new file mode 100644 index 0000000..e69de29 diff --git a/internal/preproc/filereader_mocks/app/test_app.c65 b/internal/preproc/filereader_mocks/app/test_app.c65 new file mode 100644 index 0000000..dc5907d --- /dev/null +++ b/internal/preproc/filereader_mocks/app/test_app.c65 @@ -0,0 +1,2 @@ +// library file +LABEL app \ No newline at end of file diff --git a/internal/preproc/filereader_mocks/lib/libsub/test_libsub.c65 b/internal/preproc/filereader_mocks/lib/libsub/test_libsub.c65 new file mode 100644 index 0000000..107c4c0 --- /dev/null +++ b/internal/preproc/filereader_mocks/lib/libsub/test_libsub.c65 @@ -0,0 +1,2 @@ +// library file +LABEL libsub \ No newline at end of file diff --git a/internal/preproc/filereader_mocks/lib/test_lib.c65 b/internal/preproc/filereader_mocks/lib/test_lib.c65 new file mode 100644 index 0000000..e6a9e90 --- /dev/null +++ b/internal/preproc/filereader_mocks/lib/test_lib.c65 @@ -0,0 +1,2 @@ +// library file +LABEL lib \ No newline at end of file diff --git a/internal/preproc/filereader_test.go b/internal/preproc/filereader_test.go new file mode 100644 index 0000000..25391b5 --- /dev/null +++ b/internal/preproc/filereader_test.go @@ -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: "", + wantErr: false, + }, + { + name: "nested library include", + includeSpec: "", + wantErr: false, + }, + { + name: "missing library file", + includeSpec: "", + 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("", "") + 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{ + "": {"LDA #$00", "STA $D020"}, + } + + reader := NewMockFileReader(files) + + lines, err := reader.ReadLines("", "") + 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") + } +}