Skip to content

Commit 8ba8f7d

Browse files
committed
Add support for module metadata
1 parent 1b97c1f commit 8ba8f7d

7 files changed

Lines changed: 458 additions & 47 deletions

File tree

.github/design.md

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -615,19 +615,20 @@ Using `IEnumerable<JsonElement>` with `yield return` matches jq's semantics natu
615615

616616
---
617617

618-
## 14. Module System (`include`, `import`)
618+
## 14. Module System (`include`, `import`, `module`, `modulemeta`)
619619

620620
### 14.1 Overview
621621

622-
JQSharp supports three module/data-loading forms:
622+
JQSharp supports four module/data-loading forms:
623623

624624
| Form | Purpose | Scope behavior |
625625
|------|---------|----------------|
626626
| `include "relative/path" [<metadata>];` | Inline a jq module at parse time | Definitions become directly visible (unqualified names). |
627627
| `import "relative/path" as alias [<metadata>];` | Import jq `def` declarations under a namespace alias | Definitions are exposed as `alias::name`. |
628628
| `import "relative/path" as $alias [<metadata>];` | Import JSON data as a variable binding | Data is bound as `$alias::alias`. |
629+
| `module <metadata>;` | Attach declarative metadata to module file | No direct evaluation effect; consumed by `modulemeta`. |
629630

630-
`include` performs content splicing into the current parse stream. `import` does not splice caller text: it either registers alias-prefixed function definitions (module import) or introduces a scoped variable binding (data import).
631+
`include` performs content splicing into the current parse stream. `import` does not splice caller text: it either registers alias-prefixed function definitions (module import) or introduces a scoped variable binding (data import). `module` is a top-of-module declarative statement.
631632

632633
### 14.2 JqResolver
633634

@@ -651,7 +652,30 @@ Two built-in implementations are provided:
651652
| `JqFileResolver` | Resolves paths on the file system. Appends `.jq` when no extension is present. Relative paths are resolved from the including/importing module's directory (or `BaseDirectory` for top-level calls). |
652653
| `JqResourceResolver` | Resolves paths to embedded assembly resources. Slashes are converted to dots; a configurable `Prefix` is prepended; supports nested relative include/import chains. `GetCanonicalPath` checks the **original input path** for `.jq`/`.json` before dot-normalization, and appends `.jq` only when neither extension exists. |
653654

654-
### 14.3 Include Parse-Time Content Splicing
655+
### 14.3 Module Metadata Registry and Statement
656+
657+
The parser now maintains a shared metadata registry across nested parser instances:
658+
659+
- `Dictionary<string, JsonElement> _moduleMetadataRegistry`
660+
661+
Keying:
662+
663+
- Key is the relative path token used by jq source (`"foo"` in `import "foo" as m;`), not canonical resolver path.
664+
665+
Every module can optionally begin with:
666+
667+
```jq
668+
module {"version": "1.0", "homepage": "https://example.com"};
669+
```
670+
671+
Implementation details:
672+
673+
1. `ParsePrimary()` recognizes `module` keyword and dispatches `ParseModuleStatement()`.
674+
2. The metadata expression is parsed as normal jq expression and immediately evaluated against `null` input with `JqEnvironment.Empty`.
675+
3. Result must be a single object value; it is stored as `_moduleStatementMetadata`.
676+
4. Statement consumes `;` and parsing continues with `ParsePipe()`.
677+
678+
### 14.4 Include Parse-Time Content Splicing
655679

656680
`include` is handled entirely at parse time using a **content-splice** strategy:
657681

@@ -669,15 +693,16 @@ rest_of_program
669693
When `ParseIncludeExpression()` is invoked:
670694

671695
1. The module path string is parsed (no interpolation allowed).
672-
2. An optional metadata object `{...}` is skipped (parsed but currently ignored).
696+
2. An optional metadata object `{...}` is parsed and preserved for dependency metadata.
673697
3. The terminating `;` is consumed.
674698
4. `GetCanonicalPath()` produces the cache key; if cached, file/resource IO is skipped.
675-
5. The parser captures the remaining caller text (`text[position..]`).
676-
6. Combined text is built as `moduleContent + "\n" + remainingText`.
677-
7. `ParseSubExpression(combinedText, canonicalPath)` parses the combined stream in a child parser.
678-
8. The parent advances `position` to `text.Length` because continuation is consumed by the child.
699+
5. The module is parsed in isolation first to populate metadata registry (`module` keys, `deps`, `defs`).
700+
6. The parser captures the remaining caller text (`text[position..]`).
701+
7. Combined text is built as `moduleContent + "\n" + remainingText`.
702+
8. `ParseSubExpression(combinedText, canonicalPath)` parses the combined stream in a child parser.
703+
9. The parent advances `position` to `text.Length` because continuation is consumed by the child.
679704

680-
### 14.4 Import Parse Pipeline (Phase 16.2)
705+
### 14.5 Import Parse Pipeline (Phase 16.3)
681706

682707
`import` first parses the shared prefix, then dispatches:
683708

@@ -700,7 +725,7 @@ flowchart TD
700725
F --> G
701726
```
702727

703-
### 14.5 Function Import: Parse-Time Definition Extraction
728+
### 14.6 Function Import: Parse-Time Definition Extraction
704729

705730
Function import extracts only exported `def` declarations from the child parse and re-registers them in the parent under `alias::` names.
706731

@@ -735,7 +760,7 @@ finally
735760
}
736761
```
737762

738-
### 14.6 Data Import: Variable Binding
763+
### 14.7 Data Import: Variable Binding
739764

740765
Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped variable named `$cfg::cfg`.
741766

@@ -749,7 +774,7 @@ Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped varia
749774
5. If JSON parsing fails, `JsonException` is wrapped as:
750775
- `JqException("Failed to parse JSON data from '...': ...")`
751776

752-
### 14.7 Qualified Name Resolution (`::`)
777+
### 14.8 Qualified Name Resolution (`::`)
753778

754779
`ParsePrimary()` supports `::` for both variable and identifier/function references.
755780

@@ -760,11 +785,58 @@ Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped varia
760785

761786
This ensures module-qualified symbols are explicit and never accidentally treated as builtins or filter parameters.
762787

763-
### 14.8 Caching
788+
### 14.9 Metadata Object Shape (`modulemeta`)
789+
790+
For each imported/included module, registry stores a metadata object:
791+
792+
```json
793+
{
794+
"...custom-module-keys": "...",
795+
"deps": [
796+
{
797+
"relpath": "path",
798+
"as": "alias-or-null",
799+
"is_data": false,
800+
"...import/include-metadata-keys": "..."
801+
}
802+
],
803+
"defs": ["name/arity"]
804+
}
805+
```
806+
807+
Notes:
808+
809+
- `deps` entries are recorded for `include`, function `import`, and data `import`.
810+
- `as` is `null` for `include`.
811+
- `is_data` is `true` only for `import ... as $alias`.
812+
- `defs` is collected from exported defs discovered when parsing module in import/metadata-collection mode.
813+
814+
### 14.10 Runtime Environment Injection
815+
816+
`JqEnvironment` now carries immutable module metadata map. At parse entrypoint, when metadata registry is non-empty, parsed filter is wrapped in `ModuleMetadataFilter` which injects metadata into the evaluation environment.
817+
818+
### 14.11 `modulemeta` builtin
819+
820+
`modulemeta` is a zero-arg builtin that consumes input as module name:
821+
822+
```jq
823+
"foo" | modulemeta
824+
```
825+
826+
Behavior:
827+
828+
- Input must be string, else error: `modulemeta input must be a string`
829+
- Unknown module name errors: `Unknown module: 'foo'`
830+
- Returns metadata object with custom keys plus `deps` and `defs`
831+
832+
### 14.12 Caching
833+
834+
The parser now uses two shared dictionaries across nested parse operations in one `Jq.Parse()` call:
764835

765-
The parser holds `Dictionary<string, string> _moduleCache` (keyed by canonical path), shared across nested include/import operations in a single `Jq.Parse()` call. Repeated loads of the same canonical path perform only one resolver read.
836+
- `_moduleCache` (canonical path -> module text)
837+
- `_moduleMetadataRegistry` (relative module path -> metadata object)
766838

767-
### 14.9 Public API
839+
### 14.13 Public API
768840

769841
```csharp
770842
// Pass a resolver to enable include/import support.

.github/product.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ Module content is cached by path to avoid redundant parsing.
222222
- `import RelativePathString as NAME [<metadata>];`
223223
- `import RelativePathString as $NAME [<metadata>];`
224224

225-
### - [ ] Phase 16.3 — Modules Metadata
225+
### - [x] Phase 16.3 — Modules Metadata
226226

227227
- `module <metadata>;`
228228
- `modulemeta`

src/JQSharp/Filters/BuiltinFilter.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ sealed class BuiltinFilter : JqFilter
2626
"tgamma", "lgamma", "j0", "j1",
2727
"modf", "frexp",
2828
"recurse", "halt", "error", "env", "builtins",
29+
"modulemeta",
2930
"first", "last",
3031
"not",
3132
"now", "todate", "todateiso8601", "fromdate", "fromdateiso8601", "gmtime", "localtime", "mktime",
@@ -131,6 +132,7 @@ public override IEnumerable<JsonElement> Evaluate(JsonElement input, JqEnvironme
131132
"error" => throw new JqException(input),
132133
"env" => EvaluateEnv(),
133134
"builtins" => EvaluateBuiltins(),
135+
"modulemeta" => EvaluateModuleMeta(input, env),
134136
"first" => EvaluateFirst(input),
135137
"last" => EvaluateLast(input),
136138
"now" => EvaluateNow(),
@@ -1097,6 +1099,18 @@ static IEnumerable<JsonElement> EvaluateBuiltins()
10971099
});
10981100
}
10991101

1102+
static IEnumerable<JsonElement> EvaluateModuleMeta(JsonElement input, JqEnvironment env)
1103+
{
1104+
if (input.ValueKind != JsonValueKind.String)
1105+
throw new JqException("modulemeta input must be a string");
1106+
1107+
var moduleName = input.GetString()!;
1108+
if (!env.TryGetModuleMetadata(moduleName, out var metadata))
1109+
throw new JqException($"Unknown module: '{moduleName}'");
1110+
1111+
yield return metadata;
1112+
}
1113+
11001114
static IEnumerable<JsonElement> EvaluateFirst(JsonElement input)
11011115
{
11021116
if (input.ValueKind != JsonValueKind.Array)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Collections.Immutable;
2+
using System.Text.Json;
3+
4+
namespace Devlooped;
5+
6+
sealed class ModuleMetadataFilter(JqFilter inner, ImmutableDictionary<string, JsonElement> metadata) : JqFilter
7+
{
8+
public override IEnumerable<JsonElement> Evaluate(JsonElement input, JqEnvironment env)
9+
=> inner.Evaluate(input, env.WithModuleMetadata(metadata));
10+
}

src/JQSharp/JqEnvironment.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,28 @@ sealed class JqEnvironment
88
{
99
public static readonly JqEnvironment Empty = new(
1010
ImmutableDictionary<string, JsonElement>.Empty.WithComparers(StringComparer.Ordinal),
11-
ImmutableDictionary<string, FilterClosure>.Empty.WithComparers(StringComparer.Ordinal));
11+
ImmutableDictionary<string, FilterClosure>.Empty.WithComparers(StringComparer.Ordinal),
12+
ImmutableDictionary<string, JsonElement>.Empty.WithComparers(StringComparer.Ordinal));
1213

1314
readonly ImmutableDictionary<string, JsonElement> bindings;
1415
readonly ImmutableDictionary<string, FilterClosure> filterBindings;
16+
readonly ImmutableDictionary<string, JsonElement> moduleMetadata;
1517

1618
JqEnvironment(
1719
ImmutableDictionary<string, JsonElement> bindings,
18-
ImmutableDictionary<string, FilterClosure> filterBindings)
20+
ImmutableDictionary<string, FilterClosure> filterBindings,
21+
ImmutableDictionary<string, JsonElement> moduleMetadata)
1922
{
2023
this.bindings = bindings;
2124
this.filterBindings = filterBindings;
25+
this.moduleMetadata = moduleMetadata;
2226
}
2327

24-
public JqEnvironment Bind(string name, JsonElement value) => new(bindings.SetItem(name, value), filterBindings);
28+
public JqEnvironment Bind(string name, JsonElement value) => new(bindings.SetItem(name, value), filterBindings, moduleMetadata);
2529

26-
public JqEnvironment BindFilter(string name, FilterClosure closure) => new(bindings, filterBindings.SetItem(name, closure));
30+
public JqEnvironment BindFilter(string name, FilterClosure closure) => new(bindings, filterBindings.SetItem(name, closure), moduleMetadata);
31+
32+
public JqEnvironment WithModuleMetadata(ImmutableDictionary<string, JsonElement> metadata) => new(bindings, filterBindings, metadata);
2733

2834
public JsonElement Get(string name)
2935
{
@@ -36,4 +42,6 @@ public JsonElement Get(string name)
3642
public bool TryGet(string name, out JsonElement value) => bindings.TryGetValue(name, out value);
3743

3844
public bool TryGetFilter(string name, [MaybeNullWhen(false)] out FilterClosure closure) => filterBindings.TryGetValue(name, out closure);
45+
46+
public bool TryGetModuleMetadata(string name, out JsonElement metadata) => moduleMetadata.TryGetValue(name, out metadata);
3947
}

0 commit comments

Comments
 (0)