diff --git a/.github/design.md b/.github/design.md index df0aa8d..da7a0de 100644 --- a/.github/design.md +++ b/.github/design.md @@ -615,19 +615,20 @@ Using `IEnumerable` with `yield return` matches jq's semantics natu --- -## 14. Module System (`include`, `import`) +## 14. Module System (`include`, `import`, `module`, `modulemeta`) ### 14.1 Overview -JQSharp supports three module/data-loading forms: +JQSharp supports four module/data-loading forms: | Form | Purpose | Scope behavior | |------|---------|----------------| | `include "relative/path" [];` | Inline a jq module at parse time | Definitions become directly visible (unqualified names). | | `import "relative/path" as alias [];` | Import jq `def` declarations under a namespace alias | Definitions are exposed as `alias::name`. | | `import "relative/path" as $alias [];` | Import JSON data as a variable binding | Data is bound as `$alias::alias`. | +| `module ;` | Attach declarative metadata to module file | No direct evaluation effect; consumed by `modulemeta`. | -`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). +`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. ### 14.2 JqResolver @@ -651,7 +652,30 @@ Two built-in implementations are provided: | `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). | | `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. | -### 14.3 Include Parse-Time Content Splicing +### 14.3 Module Metadata Registry and Statement + +The parser now maintains a shared metadata registry across nested parser instances: + +- `Dictionary _moduleMetadataRegistry` + +Keying: + +- Key is the relative path token used by jq source (`"foo"` in `import "foo" as m;`), not canonical resolver path. + +Every module can optionally begin with: + +```jq +module {"version": "1.0", "homepage": "https://example.com"}; +``` + +Implementation details: + +1. `ParsePrimary()` recognizes `module` keyword and dispatches `ParseModuleStatement()`. +2. The metadata expression is parsed as normal jq expression and immediately evaluated against `null` input with `JqEnvironment.Empty`. +3. Result must be a single object value; it is stored as `_moduleStatementMetadata`. +4. Statement consumes `;` and parsing continues with `ParsePipe()`. + +### 14.4 Include Parse-Time Content Splicing `include` is handled entirely at parse time using a **content-splice** strategy: @@ -669,15 +693,16 @@ rest_of_program When `ParseIncludeExpression()` is invoked: 1. The module path string is parsed (no interpolation allowed). -2. An optional metadata object `{...}` is skipped (parsed but currently ignored). +2. An optional metadata object `{...}` is parsed and preserved for dependency metadata. 3. The terminating `;` is consumed. 4. `GetCanonicalPath()` produces the cache key; if cached, file/resource IO is skipped. -5. The parser captures the remaining caller text (`text[position..]`). -6. Combined text is built as `moduleContent + "\n" + remainingText`. -7. `ParseSubExpression(combinedText, canonicalPath)` parses the combined stream in a child parser. -8. The parent advances `position` to `text.Length` because continuation is consumed by the child. +5. The module is parsed in isolation first to populate metadata registry (`module` keys, `deps`, `defs`). +6. The parser captures the remaining caller text (`text[position..]`). +7. Combined text is built as `moduleContent + "\n" + remainingText`. +8. `ParseSubExpression(combinedText, canonicalPath)` parses the combined stream in a child parser. +9. The parent advances `position` to `text.Length` because continuation is consumed by the child. -### 14.4 Import Parse Pipeline (Phase 16.2) +### 14.5 Import Parse Pipeline (Phase 16.3) `import` first parses the shared prefix, then dispatches: @@ -700,7 +725,7 @@ flowchart TD F --> G ``` -### 14.5 Function Import: Parse-Time Definition Extraction +### 14.6 Function Import: Parse-Time Definition Extraction Function import extracts only exported `def` declarations from the child parse and re-registers them in the parent under `alias::` names. @@ -735,7 +760,7 @@ finally } ``` -### 14.6 Data Import: Variable Binding +### 14.7 Data Import: Variable Binding Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped variable named `$cfg::cfg`. @@ -749,7 +774,7 @@ Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped varia 5. If JSON parsing fails, `JsonException` is wrapped as: - `JqException("Failed to parse JSON data from '...': ...")` -### 14.7 Qualified Name Resolution (`::`) +### 14.8 Qualified Name Resolution (`::`) `ParsePrimary()` supports `::` for both variable and identifier/function references. @@ -760,11 +785,58 @@ Data import (`import "data" as $cfg;`) loads JSON and binds it as a scoped varia This ensures module-qualified symbols are explicit and never accidentally treated as builtins or filter parameters. -### 14.8 Caching +### 14.9 Metadata Object Shape (`modulemeta`) + +For each imported/included module, registry stores a metadata object: + +```json +{ + "...custom-module-keys": "...", + "deps": [ + { + "relpath": "path", + "as": "alias-or-null", + "is_data": false, + "...import/include-metadata-keys": "..." + } + ], + "defs": ["name/arity"] +} +``` + +Notes: + +- `deps` entries are recorded for `include`, function `import`, and data `import`. +- `as` is `null` for `include`. +- `is_data` is `true` only for `import ... as $alias`. +- `defs` is collected from exported defs discovered when parsing module in import/metadata-collection mode. + +### 14.10 Runtime Environment Injection + +`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. + +### 14.11 `modulemeta` builtin + +`modulemeta` is a zero-arg builtin that consumes input as module name: + +```jq +"foo" | modulemeta +``` + +Behavior: + +- Input must be string, else error: `modulemeta input must be a string` +- Unknown module name errors: `Unknown module: 'foo'` +- Returns metadata object with custom keys plus `deps` and `defs` + +### 14.12 Caching + +The parser now uses two shared dictionaries across nested parse operations in one `Jq.Parse()` call: -The parser holds `Dictionary _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. +- `_moduleCache` (canonical path -> module text) +- `_moduleMetadataRegistry` (relative module path -> metadata object) -### 14.9 Public API +### 14.13 Public API ```csharp // Pass a resolver to enable include/import support. diff --git a/.github/product.md b/.github/product.md index 8f91cf3..9716806 100644 --- a/.github/product.md +++ b/.github/product.md @@ -222,7 +222,7 @@ Module content is cached by path to avoid redundant parsing. - `import RelativePathString as NAME [];` - `import RelativePathString as $NAME [];` -### - [ ] Phase 16.3 — Modules Metadata +### - [x] Phase 16.3 — Modules Metadata - `module ;` - `modulemeta` diff --git a/src/JQSharp/Filters/BuiltinFilter.cs b/src/JQSharp/Filters/BuiltinFilter.cs index 8b22fff..885e932 100644 --- a/src/JQSharp/Filters/BuiltinFilter.cs +++ b/src/JQSharp/Filters/BuiltinFilter.cs @@ -26,6 +26,7 @@ sealed class BuiltinFilter : JqFilter "tgamma", "lgamma", "j0", "j1", "modf", "frexp", "recurse", "halt", "error", "env", "builtins", + "modulemeta", "first", "last", "not", "now", "todate", "todateiso8601", "fromdate", "fromdateiso8601", "gmtime", "localtime", "mktime", @@ -131,6 +132,7 @@ public override IEnumerable Evaluate(JsonElement input, JqEnvironme "error" => throw new JqException(input), "env" => EvaluateEnv(), "builtins" => EvaluateBuiltins(), + "modulemeta" => EvaluateModuleMeta(input, env), "first" => EvaluateFirst(input), "last" => EvaluateLast(input), "now" => EvaluateNow(), @@ -1097,6 +1099,18 @@ static IEnumerable EvaluateBuiltins() }); } + static IEnumerable EvaluateModuleMeta(JsonElement input, JqEnvironment env) + { + if (input.ValueKind != JsonValueKind.String) + throw new JqException("modulemeta input must be a string"); + + var moduleName = input.GetString()!; + if (!env.TryGetModuleMetadata(moduleName, out var metadata)) + throw new JqException($"Unknown module: '{moduleName}'"); + + yield return metadata; + } + static IEnumerable EvaluateFirst(JsonElement input) { if (input.ValueKind != JsonValueKind.Array) diff --git a/src/JQSharp/Filters/ModuleMetadataFilter.cs b/src/JQSharp/Filters/ModuleMetadataFilter.cs new file mode 100644 index 0000000..75f0227 --- /dev/null +++ b/src/JQSharp/Filters/ModuleMetadataFilter.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; +using System.Text.Json; + +namespace Devlooped; + +sealed class ModuleMetadataFilter(JqFilter inner, ImmutableDictionary metadata) : JqFilter +{ + public override IEnumerable Evaluate(JsonElement input, JqEnvironment env) + => inner.Evaluate(input, env.WithModuleMetadata(metadata)); +} diff --git a/src/JQSharp/JqEnvironment.cs b/src/JQSharp/JqEnvironment.cs index 11014d1..97d44da 100644 --- a/src/JQSharp/JqEnvironment.cs +++ b/src/JQSharp/JqEnvironment.cs @@ -8,22 +8,28 @@ sealed class JqEnvironment { public static readonly JqEnvironment Empty = new( ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal)); + ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), + ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal)); readonly ImmutableDictionary bindings; readonly ImmutableDictionary filterBindings; + readonly ImmutableDictionary moduleMetadata; JqEnvironment( ImmutableDictionary bindings, - ImmutableDictionary filterBindings) + ImmutableDictionary filterBindings, + ImmutableDictionary moduleMetadata) { this.bindings = bindings; this.filterBindings = filterBindings; + this.moduleMetadata = moduleMetadata; } - public JqEnvironment Bind(string name, JsonElement value) => new(bindings.SetItem(name, value), filterBindings); + public JqEnvironment Bind(string name, JsonElement value) => new(bindings.SetItem(name, value), filterBindings, moduleMetadata); - public JqEnvironment BindFilter(string name, FilterClosure closure) => new(bindings, filterBindings.SetItem(name, closure)); + public JqEnvironment BindFilter(string name, FilterClosure closure) => new(bindings, filterBindings.SetItem(name, closure), moduleMetadata); + + public JqEnvironment WithModuleMetadata(ImmutableDictionary metadata) => new(bindings, filterBindings, metadata); public JsonElement Get(string name) { @@ -36,4 +42,6 @@ public JsonElement Get(string name) public bool TryGet(string name, out JsonElement value) => bindings.TryGetValue(name, out value); public bool TryGetFilter(string name, [MaybeNullWhen(false)] out FilterClosure closure) => filterBindings.TryGetValue(name, out closure); + + public bool TryGetModuleMetadata(string name, out JsonElement metadata) => moduleMetadata.TryGetValue(name, out metadata); } diff --git a/src/JQSharp/JqParser.cs b/src/JQSharp/JqParser.cs index f25b26e..7f5b89e 100644 --- a/src/JQSharp/JqParser.cs +++ b/src/JQSharp/JqParser.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Globalization; using System.Text; using System.Text.Json; @@ -13,20 +14,41 @@ sealed class JqParser readonly HashSet _definedFilterParams = new(StringComparer.Ordinal); readonly JqResolver? _resolver; readonly Dictionary _moduleCache; + readonly Dictionary _moduleMetadataRegistry; readonly string? _currentModulePath; + readonly string? _currentModuleRelPath; + JsonElement? _moduleStatementMetadata; + List? _moduleDeps; int position; - public JqParser(string text, JqResolver? resolver = null, Dictionary? moduleCache = null, string? currentModulePath = null) + public JqParser( + string text, + JqResolver? resolver = null, + Dictionary? moduleCache = null, + Dictionary? moduleMetadataRegistry = null, + string? currentModulePath = null, + string? currentModuleRelPath = null) { this.text = text ?? throw new ArgumentNullException(nameof(text)); _resolver = resolver; _moduleCache = moduleCache ?? new(); + _moduleMetadataRegistry = moduleMetadataRegistry ?? new(StringComparer.Ordinal); _currentModulePath = currentModulePath; + _currentModuleRelPath = currentModuleRelPath; } public static JqFilter Parse(string expression, JqResolver? resolver = null) { - return new JqParser(expression, resolver).Parse(); + var parser = new JqParser(expression, resolver); + var filter = parser.Parse(); + if (parser._moduleMetadataRegistry.Count > 0) + { + filter = new ModuleMetadataFilter( + filter, + parser._moduleMetadataRegistry.ToImmutableDictionary(StringComparer.Ordinal)); + } + + return filter; } public JqFilter Parse() @@ -398,6 +420,9 @@ JqFilter ParsePrimary() if (TryConsumeKeyword("import")) return ParseImportExpression(); + if (TryConsumeKeyword("module")) + return ParseModuleStatement(); + if (TryConsumeSequence("..")) return new RecurseFilter(); @@ -739,6 +764,21 @@ JqFilter ParseBreakExpression() return new BreakFilter(name); } + JqFilter ParseModuleStatement() + { + SkipWhitespace(); + var metadataExpression = ParsePipe(); + var metadata = EvaluateConstantExpression(metadataExpression); + if (metadata.ValueKind != JsonValueKind.Object) + throw new JqException("module metadata must be an object"); + + _moduleStatementMetadata = metadata.Clone(); + + SkipWhitespace(); + Expect(';'); + return ParsePipe(); + } + JqFilter ParseIncludeExpression() { if (_resolver is null) @@ -752,10 +792,13 @@ JqFilter ParseIncludeExpression() var modulePath = ParseStringContent(); - // Skip optional metadata object, e.g. {"search": "."} + // Parse optional metadata object, e.g. {"search": "."} SkipWhitespace(); + JsonElement? includeMetadata = null; if (Peek() == '{') - SkipBalanced('{', '}'); + includeMetadata = ParseMetadataObject(); + + AddModuleDependency(modulePath, alias: null, isData: false, includeMetadata); SkipWhitespace(); Expect(';'); @@ -769,6 +812,9 @@ JqFilter ParseIncludeExpression() _moduleCache[canonicalPath] = moduleContent; } + // Parse in isolation first so metadata/defs for the included module are captured. + ParseModuleForMetadata(modulePath, canonicalPath, moduleContent); + // Splice: module definitions followed by whatever remains of the current program. var remainingText = text[position..]; var combinedText = moduleContent + "\n" + remainingText; @@ -777,7 +823,7 @@ JqFilter ParseIncludeExpression() // so the parent parser (Parse()) doesn't see leftover text. position = text.Length; - return ParseSubExpression(combinedText, canonicalPath); + return ParseSubExpression(combinedText, canonicalPath, _currentModuleRelPath); } JqFilter ParseImportExpression() @@ -806,8 +852,11 @@ JqFilter ParseFunctionImport(string modulePath) var alias = ParseIdentifier(); SkipWhitespace(); + JsonElement? importMetadata = null; if (Peek() == '{') - SkipBalanced('{', '}'); + importMetadata = ParseMetadataObject(); + + AddModuleDependency(modulePath, alias, isData: false, importMetadata); SkipWhitespace(); Expect(';'); @@ -820,7 +869,7 @@ JqFilter ParseFunctionImport(string modulePath) _moduleCache[canonicalPath] = moduleContent; } - var parser = new JqParser(moduleContent + "\n.", _resolver, _moduleCache, canonicalPath) + var parser = new JqParser(moduleContent + "\n.", _resolver, _moduleCache, _moduleMetadataRegistry, canonicalPath, modulePath) { _exportedDefs = new() }; @@ -832,6 +881,7 @@ JqFilter ParseFunctionImport(string modulePath) parser._definedFilterParams.Add(param); parser.Parse(); + RegisterModuleMetadata(modulePath, parser); var importedKeys = new List<(string Name, int Arity)>(); foreach (var kvp in parser._exportedDefs) @@ -857,8 +907,11 @@ JqFilter ParseDataImport(string modulePath) var alias = ParseIdentifier(); SkipWhitespace(); + JsonElement? importMetadata = null; if (Peek() == '{') - SkipBalanced('{', '}'); + importMetadata = ParseMetadataObject(); + + AddModuleDependency(modulePath, alias, isData: true, importMetadata); SkipWhitespace(); Expect(';'); @@ -900,20 +953,6 @@ JqFilter ParseDataImport(string modulePath) } } - // Skips a balanced pair of open/close characters (e.g. '{'/'}'), ignoring - // their contents. Used to skip metadata objects in include statements. - void SkipBalanced(char open, char close) - { - Expect(open); - var depth = 1; - while (!IsAtEnd && depth > 0) - { - var ch = Consume(); - if (ch == open) depth++; - else if (ch == close) depth--; - } - } - JqFilter ParseIfElseBranch() { SkipWhitespace(); @@ -1185,9 +1224,17 @@ JqFilter ParseObjectValue() return ParseSubExpression(valueExpression); } - JqFilter ParseSubExpression(string expression, string? modulePath = null) + JqFilter ParseSubExpression(string expression, string? modulePath = null, string? moduleRelPath = null) { - var parser = new JqParser(expression, _resolver, _moduleCache, modulePath ?? _currentModulePath); + var parser = new JqParser( + expression, + _resolver, + _moduleCache, + _moduleMetadataRegistry, + modulePath ?? _currentModulePath, + moduleRelPath ?? _currentModuleRelPath); + parser._moduleStatementMetadata = _moduleStatementMetadata; + parser._moduleDeps = _moduleDeps; foreach (var variable in _definedVariables) parser._definedVariables.Add(variable); foreach (var kvp in _definedFunctions) @@ -1197,7 +1244,145 @@ JqFilter ParseSubExpression(string expression, string? modulePath = null) if (_exportedDefs is not null) parser._exportedDefs = _exportedDefs; - return parser.Parse(); + var parsed = parser.Parse(); + _moduleStatementMetadata = parser._moduleStatementMetadata; + _moduleDeps = parser._moduleDeps; + return parsed; + } + + JsonElement ParseMetadataObject() + { + var metadataExpression = ParseObjectConstructor(); + var metadata = EvaluateConstantExpression(metadataExpression); + if (metadata.ValueKind != JsonValueKind.Object) + throw new JqException("module metadata must be an object"); + return metadata.Clone(); + } + + static JsonElement EvaluateConstantExpression(JqFilter filter) + { + var results = filter.Evaluate(CreateNullElement(), JqEnvironment.Empty).ToArray(); + if (results.Length != 1) + throw new JqException("module metadata expression must produce exactly one value"); + return results[0].Clone(); + } + + static JsonElement CreateDependencyEntry(string relpath, string? alias, bool isData, JsonElement? metadata) + { + return CreateJsonElement(writer => + { + writer.WriteStartObject(); + writer.WriteString("relpath", relpath); + writer.WritePropertyName("as"); + if (alias is null) + writer.WriteNullValue(); + else + writer.WriteStringValue(alias); + + writer.WriteBoolean("is_data", isData); + + if (metadata is JsonElement metadataObject && metadataObject.ValueKind == JsonValueKind.Object) + { + foreach (var property in metadataObject.EnumerateObject()) + { + writer.WritePropertyName(property.Name); + property.Value.WriteTo(writer); + } + } + + writer.WriteEndObject(); + }); + } + + void AddModuleDependency(string relpath, string? alias, bool isData, JsonElement? metadata) + { + _moduleDeps ??= new(); + _moduleDeps.Add(CreateDependencyEntry(relpath, alias, isData, metadata)); + } + + static JsonElement CreateMetadataObject( + JsonElement? moduleStatementMetadata, + List? deps, + IEnumerable defs) + { + return CreateJsonElement(writer => + { + writer.WriteStartObject(); + + if (moduleStatementMetadata is JsonElement moduleMetadata && moduleMetadata.ValueKind == JsonValueKind.Object) + { + foreach (var property in moduleMetadata.EnumerateObject()) + { + writer.WritePropertyName(property.Name); + property.Value.WriteTo(writer); + } + } + + writer.WritePropertyName("deps"); + writer.WriteStartArray(); + if (deps is not null) + { + foreach (var dep in deps) + dep.WriteTo(writer); + } + writer.WriteEndArray(); + + writer.WritePropertyName("defs"); + writer.WriteStartArray(); + foreach (var def in defs) + writer.WriteStringValue(def); + writer.WriteEndArray(); + + writer.WriteEndObject(); + }); + } + + void RegisterModuleMetadata(string modulePath, JqParser moduleParser) + { + if (string.IsNullOrEmpty(modulePath)) + return; + + var defs = moduleParser._exportedDefs is null + ? Enumerable.Empty() + : moduleParser._exportedDefs.Keys + .Select(static key => $"{key.Name}/{key.Arity}") + .OrderBy(static value => value, StringComparer.Ordinal); + + var metadata = CreateMetadataObject(moduleParser._moduleStatementMetadata, moduleParser._moduleDeps, defs); + _moduleMetadataRegistry[modulePath] = metadata.Clone(); + } + + void ParseModuleForMetadata(string modulePath, string canonicalPath, string moduleContent) + { + if (_moduleMetadataRegistry.ContainsKey(modulePath)) + return; + + var parser = new JqParser(moduleContent + "\n.", _resolver, _moduleCache, _moduleMetadataRegistry, canonicalPath, modulePath) + { + _exportedDefs = new() + }; + parser.Parse(); + RegisterModuleMetadata(modulePath, parser); + } + + static JsonElement CreateNullElement() + { + using var document = JsonDocument.Parse("null"); + return document.RootElement.Clone(); + } + + static JsonElement CreateJsonElement(Action write) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + write(writer); + writer.Flush(); + } + + stream.Position = 0; + using var document = JsonDocument.Parse(stream); + return document.RootElement.Clone(); } string ReadUntilTopLevel(params char[] terminators) diff --git a/src/Tests/JqModuleTests.cs b/src/Tests/JqModuleTests.cs index bf84938..69f0a08 100644 --- a/src/Tests/JqModuleTests.cs +++ b/src/Tests/JqModuleTests.cs @@ -55,7 +55,7 @@ public void Include_jq_extension_added_automatically() } [Fact] - public void Include_with_metadata_object_is_skipped_and_works() + public void Include_with_metadata_object_parses_and_works() { WriteModule("helpers", "def negate: -. ;"); var resolver = new JqFileResolver(_tempDir); @@ -307,6 +307,128 @@ public void Include_resource_resolver_loads_embedded_module() Assert.Equal(["\"hello world\""], EvaluateToStrings("""include "greet"; greet""", "\"world\"", resolver)); } + [Fact] + public void Module_statement_is_transparent() + { + WriteModule("meta", """module {"version": "1.0"}; def double: . * 2;"""); + var resolver = new JqFileResolver(_tempDir); + Assert.Equal(["10"], EvaluateToStrings("""import "meta" as m; m::double""", "5", resolver)); + } + + [Fact] + public void Modulemeta_returns_custom_metadata() + { + WriteModule("meta", """module {"version": "1.0", "homepage": "https://example.com"}; def double: . * 2;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "meta" as m; "meta" | modulemeta | .version""", "null", resolver); + Assert.Equal(["\"1.0\""], result); + } + + [Fact] + public void Modulemeta_includes_defs() + { + WriteModule("funcs", "def double: . * 2; def triple: . * 3;"); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "funcs" as f; "funcs" | modulemeta | .defs""", "null", resolver); + Assert.Equal(["""["double/0","triple/0"]"""], result); + } + + [Fact] + public void Modulemeta_includes_deps() + { + WriteModule("base", "def base_fn: . + 1;"); + WriteModule("derived", """import "base" as b; def derived_fn: b::base_fn | . * 2;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps | length""", "null", resolver); + Assert.Equal(["1"], result); + } + + [Fact] + public void Modulemeta_deps_include_relpath_and_as() + { + WriteModule("base", "def base_fn: . + 1;"); + WriteModule("derived", """import "base" as b; def derived_fn: b::base_fn;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[0].relpath""", "null", resolver); + Assert.Equal(["\"base\""], result); + result = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[0].as""", "null", resolver); + Assert.Equal(["\"b\""], result); + } + + [Fact] + public void Modulemeta_without_module_statement() + { + WriteModule("plain", "def inc: . + 1;"); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "plain" as p; "plain" | modulemeta | .defs""", "null", resolver); + Assert.Equal(["""["inc/0"]"""], result); + } + + [Fact] + public void Modulemeta_with_include() + { + WriteModule("incmod", """module {"type": "include-test"}; def half: . / 2;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""include "incmod"; "incmod" | modulemeta | .type""", "null", resolver); + Assert.Equal(["\"include-test\""], result); + } + + [Fact] + public void Modulemeta_data_import_dep() + { + WriteJsonModule("data", """{"key": "val"}"""); + WriteModule("uses_data", """import "data" as $d; def get_data: $d::d;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "uses_data" as m; "uses_data" | modulemeta | .deps[0].is_data""", "null", resolver); + Assert.Equal(["true"], result); + } + + [Fact] + public void Include_metadata_captured_in_deps() + { + WriteModule("base", "def base_fn: . + 1;"); + WriteModule("uses_include", """include "base" {"search": "."}; def wrapper: base_fn;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "uses_include" as m; "uses_include" | modulemeta | .deps[0].relpath""", "null", resolver); + Assert.Equal(["\"base\""], result); + result = EvaluateToStrings("""import "uses_include" as m; "uses_include" | modulemeta | .deps[0].search""", "null", resolver); + Assert.Equal(["\".\""], result); + } + + [Fact] + public void Import_metadata_captured_in_deps() + { + WriteModule("base", "def base_fn: . + 1;"); + WriteModule("uses_import", """import "base" as b {"search": "."}; def wrapper: b::base_fn;"""); + var resolver = new JqFileResolver(_tempDir); + var result = EvaluateToStrings("""import "uses_import" as m; "uses_import" | modulemeta | .deps[0].search""", "null", resolver); + Assert.Equal(["\".\""], result); + } + + [Fact] + public void Modulemeta_deps_include_relpath_as_and_is_data_flags() + { + WriteModule("base", "def base_fn: . + 1;"); + WriteModule("helpers", "def helper: . * 2;"); + WriteModule("derived", """include "base"; import "helpers" as h; def run: h::helper | base_fn;"""); + var resolver = new JqFileResolver(_tempDir); + + var includeAs = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[0].as""", "null", resolver); + Assert.Equal(["null"], includeAs); + + var includeIsData = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[0].is_data""", "null", resolver); + Assert.Equal(["false"], includeIsData); + + var importRelpath = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[] | select(.as == "h") | .relpath""", "null", resolver); + Assert.Equal(["\"helpers\""], importRelpath); + + var importAs = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[] | select(.as == "h") | .as""", "null", resolver); + Assert.Equal(["\"h\""], importAs); + + var importIsData = EvaluateToStrings("""import "derived" as d; "derived" | modulemeta | .deps[] | select(.as == "h") | .is_data""", "null", resolver); + Assert.Equal(["false"], importIsData); + } + // Helper resolver that wraps another and counts Resolve() calls. sealed class CountingResolver(JqResolver inner, Action onResolve) : JqResolver {