Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,23 @@ The ports are not line-for-line identical. Current intentional/known gaps:
`"main"` for bare references, and tries implicit extensions / index files.
It does not implement Node's full module-resolution algorithm (e.g.
conditional `"exports"`).
- **No virtual filesystem in Go.** The TS resolvers accept a `ctx.meta.fs`
(used by tests via `memfs`); the Go resolvers use the OS filesystem
directly.
- **Virtual filesystem injection differs in shape.** Both ports let the
file/pkg resolvers read from an injected filesystem instead of the OS (the
default in both). TS uses `ctx.meta.fs` — a `node:fs` subset (e.g. `memfs`)
keyed by absolute paths. Go uses `MultiSourceOptions.FS` or `ctx.Meta["fs"]`
— an `io/fs.FS` (e.g. `testing/fstest.MapFS`) keyed by relative,
slash-separated paths (`fs.ValidPath`). Because an `io/fs.FS` is rooted and
relative, a Go reference under an injected FS resolves relative to the FS
root, not as an absolute path.
- **No `.js` processor in Go.** Executing JavaScript modules is Node-specific.
- **Base-path resolution.** The Go plugin resolves a reference against
`opts.Path` once, before calling the resolver; it does not yet track each
parent file's directory for relative nested includes the way the TS
`resolvePathSpec` does via `ctx.meta.multisource.path`.

Nested relative includes have parity: like TS, the Go plugin threads each
loaded source's full path through `ctx.Meta["multisource"]["path"]`, so a
relative reference inside a loaded source resolves against *that* source's own
directory (a → b → c, at any depth), and sibling loads are independent. The
base path for a top-level parse is still `opts.Path` (Go's equivalent of
seeding `ctx.meta.multisource.path` in TS); from there each nested source's
directory is tracked automatically.

If you close any of these gaps, update this list and both `doc/` files.

Expand Down
85 changes: 77 additions & 8 deletions doc/multisource-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,17 @@ out, _ := j.Parse(`{cfg: @"some-pkg/config.jsonic"}`)
A bare package reference (`@"some-pkg"`) resolves via the package's
`package.json` `"main"`, falling back to index files.

A relative reference (`@"./x"`, `@"../x"`) found *inside* a source loaded from a
package resolves against that source's own directory — not as a package name —
so a package can pull in its own sibling files.

### Supply a custom resolver

Implement the `Resolver` function type. It must populate `Resolution.Found`
and — if found — `Src` and `Full`:

```go
httpResolver := func(spec multisource.PathSpec, _ *multisource.MultiSourceOptions) multisource.Resolution {
httpResolver := func(spec multisource.PathSpec, _ *multisource.MultiSourceOptions, _ *jsonic.Context) multisource.Resolution {
body := httpGet(spec.Full)
return multisource.Resolution{
PathSpec: spec,
Expand All @@ -130,6 +134,34 @@ httpResolver := func(spec multisource.PathSpec, _ *multisource.MultiSourceOption
j := multisource.MakeJsonic(multisource.MultiSourceOptions{Resolver: httpResolver})
```

### Resolve from an in-memory filesystem (hermetic tests)

The file and pkg resolvers read from the OS by default. Supply an `io/fs.FS`
(for example `testing/fstest.MapFS`) via `FS` to read from memory instead — handy
for hermetic tests, mirroring the `ctx.meta.fs` injection used by the TypeScript
tests. Paths under an injected FS are relative and slash-separated (see
`fs.ValidPath`), so they resolve relative to the FS root:

```go
fsys := fstest.MapFS{
"main.jsonic": &fstest.MapFile{Data: []byte(`{child:@"./sub/child.jsonic"}`)},
"sub/child.jsonic": &fstest.MapFile{Data: []byte(`{v:99}`)},
}
j := multisource.MakeJsonic(multisource.MultiSourceOptions{
Resolver: multisource.MakeFileResolver(),
FS: fsys,
})
out, _ := j.Parse(`@"./main.jsonic"`)
// out == map[string]any{"child": map[string]any{"v": float64(99)}}
```

A per-parse override can also be passed as `ctx.Meta["fs"]` via `ParseMeta`,
matching the TypeScript `j('...', { fs })` form:

```go
out, _ := j.ParseMeta(`@"./main.jsonic"`, map[string]any{"fs": fsys})
```

### Register a processor for a new file kind

Processors fill in `res.Val` from `res.Src`. Register them under the kind
Expand All @@ -139,7 +171,7 @@ Processors fill in `res.Val` from `res.Src`. Register them under the kind
j := multisource.MakeJsonic(multisource.MultiSourceOptions{
Resolver: multisource.MakeMemResolver(files),
Processor: map[string]multisource.Processor{
"yaml": func(res *multisource.Resolution, _ *multisource.MultiSourceOptions, _ *jsonic.Jsonic) {
"yaml": func(res *multisource.Resolution, _ *multisource.MultiSourceOptions, _ *jsonic.Context, _ *jsonic.Jsonic) {
res.Val = parseYAML(res.Src)
},
},
Expand Down Expand Up @@ -186,16 +218,41 @@ All added alternates share the `multisource` group tag, supplied via the

1. The directive action reads the reference — a string, or a map with a
`path` key.
2. `ResolvePathSpec` normalises the string into a `PathSpec` (kind, base,
2. The base directory is chosen: `opts.Path` for a top-level parse, or the
directory of the enclosing source for a nested reference (see below).
3. `ResolvePathSpec` normalises the string into a `PathSpec` (kind, base,
full, abs).
3. The configured `Resolver` attempts to load the source, optionally
4. The configured `Resolver` attempts to load the source, optionally
trying implicit extensions and `index.<ext>` variants.
4. A `Processor` is selected from `Processor[kind]` (or the default
5. A `Processor` is selected from `Processor[kind]` (or the default
processor for unknown kinds) and converts the source string to a
Go value.
5. The value is spliced into the surrounding parse tree; at pair level,
6. The value is spliced into the surrounding parse tree; at pair level,
a map value is merged into the parent.

### Nested relative references

When a loaded source itself contains references, each relative reference
resolves against the directory of the source that contains it — not against the
top-level `opts.Path`. This mirrors the canonical TypeScript plugin.

The mechanism: before processing a loaded source, the plugin records that
source's full path in `ctx.Meta["multisource"]["path"]` (and pushes the
previous path onto `ctx.Meta["multisource"]["parents"]`). `JsonicProcessor`
threads this meta into the nested parse via `ParseMeta`, so when the nested
parse encounters a reference, the base directory is taken from the enclosing
source's path. The parent parse context is copied rather than mutated, so this
works at any nesting depth and sibling loads remain independent.

For example, with `main.jsonic` containing `child:@"./sub/child.jsonic"` and
`sub/child.jsonic` containing `grand:@"./grand.jsonic"`, the `./grand.jsonic`
reference resolves to `sub/grand.jsonic` (relative to `child.jsonic`), not to a
top-level `grand.jsonic`.

This holds for every resolver: a relative reference inside a source loaded by
`MakePkgResolver` resolves against that source's directory too, rather than
being treated as a `node_modules` package name.


## Reference

Expand Down Expand Up @@ -233,6 +290,7 @@ Convenience wrapper around `MakeJsonic().Parse(src)`.
| `MarkChar` | `string` | `"@"` | Directive open character. |
| `Processor` | `map[string]Processor` | `json`, `jsonic`, `jsc`, default | Per-kind source transformers. |
| `ImplicitExt` | `[]string` | `[".jsonic", ".jsc", ".json"]` | Extensions tried when omitted. |
| `FS` | `fs.FS` | `nil` (OS filesystem) | Filesystem for file/pkg resolvers. |

### Resolvers and processors

Expand Down Expand Up @@ -286,7 +344,18 @@ type Resolution struct {
Search []string
}

type Resolver func(spec PathSpec, opts *MultiSourceOptions) Resolution
type Resolver func(spec PathSpec, opts *MultiSourceOptions, ctx *jsonic.Context) Resolution

type Processor func(res *Resolution, opts *MultiSourceOptions, j *jsonic.Jsonic)
type Processor func(res *Resolution, opts *MultiSourceOptions, ctx *jsonic.Context, j *jsonic.Jsonic)
```

The `ctx` passed to a `Resolver` lets it read a per-parse filesystem from
`ctx.Meta["fs"]` (an `io/fs.FS`); resolvers fall back to `opts.FS` and then the
OS filesystem.

`ctx.Meta` carries the parse metadata for the current load, including a
`"multisource"` entry whose `"path"` is the full path of the source being
processed and whose `"parents"` is the chain of enclosing source paths. A
processor that re-parses source (as `JsonicProcessor` does, via `ParseMeta`)
must thread `ctx.Meta` through so that relative references inside the source
resolve against the source's own directory.
17 changes: 17 additions & 0 deletions doc/multisource-ts.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,23 @@ When a reference has no explicit extension, the resolver walks the
`path/index + ext`. The first existing source wins; the detected kind
determines which processor is used.

### Nested relative references

When a loaded source itself contains references, each relative reference
resolves against the directory of the source that contains it — not against the
top-level `path` option. The plugin records each loaded source's full path in
`ctx.meta.multisource.path` (and the chain of enclosing paths in
`ctx.meta.multisource.parents`); the jsonic processor threads this meta into
the nested parse (`jsonic(res.src, ctx.meta)`), and `resolvePathSpec` uses the
enclosing source's directory as the base. So with `main.jsonic` containing
`child:@"./sub/child.jsonic"` and `sub/child.jsonic` containing
`grand:@"./grand.jsonic"`, the `./grand.jsonic` reference resolves to
`sub/grand.jsonic` (relative to `child.jsonic`), at any nesting depth.

This holds for every resolver: a relative reference inside a source loaded by
`makePkgResolver` resolves against that source's directory too, rather than
being treated as a `node_modules` package name.

### Directive-level grammar

multisource registers three grammar tweaks under the `multisource` group
Expand Down
157 changes: 157 additions & 0 deletions go/fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/* Copyright (c) 2025 Richard Rodger, MIT License */

package multisource

import (
"testing"
"testing/fstest"
)

// mapFS builds an in-memory fs.FS (testing/fstest.MapFS) from a path -> content
// map. It is the Go counterpart to the memfs used by the TypeScript tests.
func mapFS(files map[string]string) fstest.MapFS {
m := make(fstest.MapFS, len(files))
for k, v := range files {
m[k] = &fstest.MapFile{Data: []byte(v)}
}
return m
}

// TestFileResolverFS checks that the file resolver reads from an injected
// io/fs.FS (via MultiSourceOptions.FS) instead of the OS, covering explicit
// extensions, implicit extensions, index files, JSON, and a sub-directory base.
func TestFileResolverFS(t *testing.T) {
fsys := mapFS(map[string]string{
"a.jsonic": `{a:1}`,
"b.jsonic": `{b:2}`,
"mod/index.jsonic": `{m:3}`,
"data/cfg.json": `{"k":4}`,
})

j := MakeJsonic(MultiSourceOptions{Resolver: MakeFileResolver(), FS: fsys})

cases := []struct {
src string
want any
}{
{`{x:@a.jsonic}`, map[string]any{"a": float64(1)}}, // explicit ext
{`{x:@b}`, map[string]any{"b": float64(2)}}, // implicit ext
{`{x:@mod}`, map[string]any{"m": float64(3)}}, // index file
{`{x:@"data/cfg.json"}`, map[string]any{"k": float64(4)}}, // json, sub-dir
}
for _, c := range cases {
r, err := j.Parse(c.src)
if err != nil {
t.Fatalf("%s: %v", c.src, err)
}
m, _ := r.(map[string]any)
assert(t, c.src, m["x"], c.want)
}
}

// TestFileResolverFSViaMeta checks the per-parse filesystem override passed as
// ctx.Meta["fs"], mirroring the TypeScript j('...', { fs }). The fs must also
// propagate to nested loads (threaded through the copied parse meta).
func TestFileResolverFSViaMeta(t *testing.T) {
fsys := mapFS(map[string]string{
"main.jsonic": `{child:@"./sub/c.jsonic"}`,
"sub/c.jsonic": `{v:7}`,
})

// No instance-level FS: the filesystem comes from the parse meta only.
j := MakeJsonic(MultiSourceOptions{Resolver: MakeFileResolver()})

r, err := j.ParseMeta(`@"./main.jsonic"`, map[string]any{"fs": fsys})
if err != nil {
t.Fatal(err)
}
assert(t, "fs-via-meta", r, map[string]any{
"child": map[string]any{"v": float64(7)},
})
}

// TestPkgResolverFS checks that the pkg resolver reads from an injected
// io/fs.FS, covering a sub-path reference, an index file, and package.json
// "main".
func TestPkgResolverFS(t *testing.T) {
fsys := mapFS(map[string]string{
"node_modules/mypkg/zed.jsonic": `{zed:99}`,
"node_modules/idxpkg/index.jsonic": `{i:5}`,
"node_modules/mainpkg/package.json": `{"main":"main.jsonic"}`,
"node_modules/mainpkg/main.jsonic": `{z:11}`,
})

j := MakeJsonic(MultiSourceOptions{
Resolver: MakePkgResolver(PkgResolverOptions{Paths: []string{"."}}),
FS: fsys,
})

cases := []struct {
src string
want any
}{
{`{c:@"mypkg/zed.jsonic"}`, map[string]any{"zed": float64(99)}}, // sub-path
{`{c:@"idxpkg"}`, map[string]any{"i": float64(5)}}, // index
{`{c:@"mainpkg"}`, map[string]any{"z": float64(11)}}, // "main"
}
for _, c := range cases {
r, err := j.Parse(c.src)
if err != nil {
t.Fatalf("%s: %v", c.src, err)
}
m, _ := r.(map[string]any)
assert(t, c.src, m["c"], c.want)
}
}

// TestPkgResolverFSWalkUp checks that, with an injected filesystem, the pkg
// resolver still walks up parent directories to find node_modules.
func TestPkgResolverFSWalkUp(t *testing.T) {
fsys := mapFS(map[string]string{
"node_modules/mypkg/zed.jsonic": `{zed:99}`,
// The reference is resolved from a nested starting directory.
"a/b/c/.keep": ``,
})

j := MakeJsonic(MultiSourceOptions{
Resolver: MakePkgResolver(PkgResolverOptions{Paths: []string{"a/b/c"}}),
FS: fsys,
})

r, err := j.Parse(`{c:@"mypkg/zed.jsonic"}`)
if err != nil {
t.Fatal(err)
}
m, _ := r.(map[string]any)
assert(t, "pkg-fs-walkup", m["c"], map[string]any{"zed": float64(99)})
}

// TestPkgResolverRelativeInPkg checks that a relative reference (./x, ../x)
// found *inside* a source loaded from a package resolves against that source's
// own directory rather than being treated as a node_modules package name.
// Covers an explicit extension, an implicit extension, and a sub-directory.
func TestPkgResolverRelativeInPkg(t *testing.T) {
fsys := mapFS(map[string]string{
"node_modules/relpkg/index.jsonic": `{a:1, b:@"./child.jsonic", c:@"./leaf", d:@"./sub/deep.jsonic"}`,
"node_modules/relpkg/child.jsonic": `{x:10}`,
"node_modules/relpkg/leaf.jsonic": `{y:20}`,
"node_modules/relpkg/sub/deep.jsonic": `{z:30}`,
})

j := MakeJsonic(MultiSourceOptions{
Resolver: MakePkgResolver(PkgResolverOptions{Paths: []string{"."}}),
FS: fsys,
})

r, err := j.Parse(`{r:@"relpkg"}`)
if err != nil {
t.Fatal(err)
}
m, _ := r.(map[string]any)
assert(t, "pkg-relative-internal", m["r"], map[string]any{
"a": float64(1),
"b": map[string]any{"x": float64(10)},
"c": map[string]any{"y": float64(20)},
"d": map[string]any{"z": float64(30)},
})
}
Loading
Loading