@malloydata/malloy 0.0.386 → 0.0.388

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -133,27 +133,42 @@ export declare class MalloyConfig {
133
133
  */
134
134
  wrapConnections(wrapper: (base: LookupConnection<Connection>) => LookupConnection<Connection>): void;
135
135
  /**
136
- * Notify every connection this config has handed out that it is time to
137
- * release its resources, then drop them from the internal cache.
136
+ * Notify every connection this config has handed out that the host is
137
+ * done with it, applying the requested disposition to its backend
138
+ * resources. Two policies:
138
139
  *
139
- * Most callers should use `Runtime.releaseConnections()` instead — the
140
- * expected contract is one MalloyConfig per Runtime, and the runtime is
141
- * the natural handle for lifecycle. This method exists because Runtime
140
+ * - `'close'` (default) destructive. Calls `Connection.close()` on each
141
+ * cached entry and drops the cache. Subsequent operations on those
142
+ * connection objects may fail. Use this at real shutdown: process
143
+ * exit, extension deactivate, config-file change.
144
+ *
145
+ * - `'idle'` — reversible. Calls `Connection.idle()` on each cached entry
146
+ * without dropping the cache. The same Connection objects are reused
147
+ * on next lookup; schema cache and other in-process state survive.
148
+ * The next operation transparently reattaches any backend resources
149
+ * that were released. Use this between operations in long-lived hosts
150
+ * (VSCode, MCP servers, anything that builds Runtimes per request) to
151
+ * release file locks / pooled sockets while the host is otherwise
152
+ * idle.
153
+ *
154
+ * Most callers should use `Runtime.shutdown(...)` instead — the expected
155
+ * contract is one MalloyConfig per Runtime, and the runtime is the
156
+ * natural handle for lifecycle. This method exists because Runtime
142
157
  * forwards to it, and for the rare case of a MalloyConfig constructed
143
158
  * without an accompanying Runtime.
144
159
  *
145
160
  * MalloyConfig does not own any connection resources itself — pools,
146
161
  * sockets, file handles, and in-process databases all live inside the
147
162
  * individual Connection objects. What the managed lookup owns is a cache
148
- * of `name → Connection` populated lazily as callers request connections.
149
- * This method walks that cache and calls `Connection.close()` on each
150
- * entry, which is the signal each connection uses to shut down whatever
151
- * resources it actually holds.
152
- *
153
- * Connections that were never looked up were never constructed and have
154
- * nothing to release; they are skipped. Wrapping lookups installed via
155
- * `wrapConnections()` do not interfere — the managed lookup under the
156
- * wrap is the one holding the cache.
163
+ * of `name → Connection` populated lazily as callers request
164
+ * connections. Connections that were never looked up were never
165
+ * constructed and have nothing to release; they are skipped. Wrapping
166
+ * lookups installed via `wrapConnections()` do not interfere — the
167
+ * managed lookup under the wrap is the one holding the cache.
168
+ */
169
+ shutdown(connections?: 'close' | 'idle'): Promise<void>;
170
+ /**
171
+ * @deprecated Use `shutdown('close')` instead.
157
172
  */
158
173
  releaseConnections(): Promise<void>;
159
174
  /**
@@ -216,30 +216,52 @@ class MalloyConfig {
216
216
  this._connections = wrapper(this._connections);
217
217
  }
218
218
  /**
219
- * Notify every connection this config has handed out that it is time to
220
- * release its resources, then drop them from the internal cache.
219
+ * Notify every connection this config has handed out that the host is
220
+ * done with it, applying the requested disposition to its backend
221
+ * resources. Two policies:
221
222
  *
222
- * Most callers should use `Runtime.releaseConnections()` instead — the
223
- * expected contract is one MalloyConfig per Runtime, and the runtime is
224
- * the natural handle for lifecycle. This method exists because Runtime
223
+ * - `'close'` (default) destructive. Calls `Connection.close()` on each
224
+ * cached entry and drops the cache. Subsequent operations on those
225
+ * connection objects may fail. Use this at real shutdown: process
226
+ * exit, extension deactivate, config-file change.
227
+ *
228
+ * - `'idle'` — reversible. Calls `Connection.idle()` on each cached entry
229
+ * without dropping the cache. The same Connection objects are reused
230
+ * on next lookup; schema cache and other in-process state survive.
231
+ * The next operation transparently reattaches any backend resources
232
+ * that were released. Use this between operations in long-lived hosts
233
+ * (VSCode, MCP servers, anything that builds Runtimes per request) to
234
+ * release file locks / pooled sockets while the host is otherwise
235
+ * idle.
236
+ *
237
+ * Most callers should use `Runtime.shutdown(...)` instead — the expected
238
+ * contract is one MalloyConfig per Runtime, and the runtime is the
239
+ * natural handle for lifecycle. This method exists because Runtime
225
240
  * forwards to it, and for the rare case of a MalloyConfig constructed
226
241
  * without an accompanying Runtime.
227
242
  *
228
243
  * MalloyConfig does not own any connection resources itself — pools,
229
244
  * sockets, file handles, and in-process databases all live inside the
230
245
  * individual Connection objects. What the managed lookup owns is a cache
231
- * of `name → Connection` populated lazily as callers request connections.
232
- * This method walks that cache and calls `Connection.close()` on each
233
- * entry, which is the signal each connection uses to shut down whatever
234
- * resources it actually holds.
235
- *
236
- * Connections that were never looked up were never constructed and have
237
- * nothing to release; they are skipped. Wrapping lookups installed via
238
- * `wrapConnections()` do not interfere — the managed lookup under the
239
- * wrap is the one holding the cache.
246
+ * of `name → Connection` populated lazily as callers request
247
+ * connections. Connections that were never looked up were never
248
+ * constructed and have nothing to release; they are skipped. Wrapping
249
+ * lookups installed via `wrapConnections()` do not interfere — the
250
+ * managed lookup under the wrap is the one holding the cache.
251
+ */
252
+ async shutdown(connections = 'close') {
253
+ if (connections === 'idle') {
254
+ await this._managedLookup.idle();
255
+ }
256
+ else {
257
+ await this._managedLookup.close();
258
+ }
259
+ }
260
+ /**
261
+ * @deprecated Use `shutdown('close')` instead.
240
262
  */
241
263
  async releaseConnections() {
242
- await this._managedLookup.close();
264
+ await this.shutdown('close');
243
265
  }
244
266
  /**
245
267
  * Query a value from the overlays used to resolve this config.
@@ -83,6 +83,13 @@ function buildManagedLookup(compiledConnections, overlays, log) {
83
83
  await conn.close();
84
84
  }
85
85
  },
86
+ async idle() {
87
+ // Cache is preserved — same Connection objects are reused so that
88
+ // schema cache and other in-process state survive the idle.
89
+ for (const conn of cache.values()) {
90
+ await conn.idle();
91
+ }
92
+ },
86
93
  };
87
94
  }
88
95
  /**
@@ -91,7 +98,14 @@ function buildManagedLookup(compiledConnections, overlays, log) {
91
98
  * gets handed to the factory.
92
99
  */
93
100
  async function resolveCompiledEntry(entry, overlays, log) {
94
- const resolved = (await resolveNode(entry, overlays, log));
101
+ const resolved = await resolveNode(entry, overlays, log);
102
+ // resolveNode returns `unknown`. The compileConnections pipeline
103
+ // guarantees every connection entry is an object dict with `is` set to
104
+ // a literal string, so this should always pass — but a structured
105
+ // throw beats a downstream NPE if a compiler bug ever sneaks through.
106
+ if (!(0, registry_1.isConnectionConfigEntry)(resolved)) {
107
+ throw new Error('Connection entry did not resolve to a valid {is: string, ...} dict');
108
+ }
95
109
  await applyPropertyDefaults(resolved, overlays);
96
110
  return resolved;
97
111
  }
@@ -126,16 +126,27 @@ export declare class Runtime {
126
126
  get virtualMap(): VirtualMap | undefined;
127
127
  set virtualMap(map: VirtualMap | undefined);
128
128
  /**
129
- * Notify every connection this runtime's config has handed out that it
130
- * is time to release its resources (pools, sockets, file handles,
131
- * in-process databases). A no-op for runtimes constructed without a
132
- * MalloyConfig in that case the caller owns the connections they
133
- * passed in and is responsible for closing them.
134
- *
135
- * The expected contract is one MalloyConfig per Runtime. Long-running
136
- * hosts (Publisher, a VS Code extension tearing down a project) should
137
- * call this when a runtime goes out of scope; one-shot CLIs can skip it
138
- * and let process exit clean up.
129
+ * Tell this runtime's connections what to do with their backend
130
+ * resources now that the host is done with this Runtime. Two policies:
131
+ *
132
+ * - `'close'` (default) destructive shutdown. Connections release
133
+ * resources and become unusable. Use at real shutdown: process exit,
134
+ * extension deactivate, config-file change.
135
+ *
136
+ * - `'idle'` reversible release. Connections release expensive
137
+ * resources (DuckDB file locks, socket pools) but stay logically
138
+ * valid. The same Connection objects are reused on next lookup;
139
+ * schema cache and other in-process state survive. Use between
140
+ * operations in long-lived hosts (a VSCode extension, an MCP server,
141
+ * any host that builds Runtimes per request) so that other writers
142
+ * can claim resources during idle gaps.
143
+ *
144
+ * A no-op for runtimes constructed without a MalloyConfig — in that
145
+ * case the caller owns the connections they passed in.
146
+ */
147
+ shutdown(connections?: 'close' | 'idle'): Promise<void>;
148
+ /**
149
+ * @deprecated Use `shutdown('close')` instead.
139
150
  */
140
151
  releaseConnections(): Promise<void>;
141
152
  /**
@@ -160,7 +160,11 @@ class Runtime {
160
160
  return undefined;
161
161
  }
162
162
  try {
163
- return JSON.parse(text);
163
+ const parsed = JSON.parse(text);
164
+ if (!isBuildManifestShape(parsed)) {
165
+ throw new Error('manifest is not an object with an "entries" map');
166
+ }
167
+ return parsed;
164
168
  }
165
169
  catch (e) {
166
170
  // File was present but couldn't be parsed. Return an empty
@@ -193,20 +197,33 @@ class Runtime {
193
197
  this._virtualMap = map;
194
198
  }
195
199
  /**
196
- * Notify every connection this runtime's config has handed out that it
197
- * is time to release its resources (pools, sockets, file handles,
198
- * in-process databases). A no-op for runtimes constructed without a
199
- * MalloyConfig in that case the caller owns the connections they
200
- * passed in and is responsible for closing them.
200
+ * Tell this runtime's connections what to do with their backend
201
+ * resources now that the host is done with this Runtime. Two policies:
202
+ *
203
+ * - `'close'` (default) destructive shutdown. Connections release
204
+ * resources and become unusable. Use at real shutdown: process exit,
205
+ * extension deactivate, config-file change.
206
+ *
207
+ * - `'idle'` — reversible release. Connections release expensive
208
+ * resources (DuckDB file locks, socket pools) but stay logically
209
+ * valid. The same Connection objects are reused on next lookup;
210
+ * schema cache and other in-process state survive. Use between
211
+ * operations in long-lived hosts (a VSCode extension, an MCP server,
212
+ * any host that builds Runtimes per request) so that other writers
213
+ * can claim resources during idle gaps.
201
214
  *
202
- * The expected contract is one MalloyConfig per Runtime. Long-running
203
- * hosts (Publisher, a VS Code extension tearing down a project) should
204
- * call this when a runtime goes out of scope; one-shot CLIs can skip it
205
- * and let process exit clean up.
215
+ * A no-op for runtimes constructed without a MalloyConfig in that
216
+ * case the caller owns the connections they passed in.
206
217
  */
207
- async releaseConnections() {
218
+ async shutdown(connections = 'close') {
208
219
  var _a;
209
- await ((_a = this._config) === null || _a === void 0 ? void 0 : _a.releaseConnections());
220
+ await ((_a = this._config) === null || _a === void 0 ? void 0 : _a.shutdown(connections));
221
+ }
222
+ /**
223
+ * @deprecated Use `shutdown('close')` instead.
224
+ */
225
+ async releaseConnections() {
226
+ await this.shutdown('close');
210
227
  }
211
228
  /**
212
229
  * Load a Malloy model by URL or contents.
@@ -532,7 +549,7 @@ class ModelMaterializer extends FluentState {
532
549
  const result = await this.loadQuery(searchMapMalloy, options).run({
533
550
  rowLimit: 1000,
534
551
  });
535
- const rawResult = result._queryResult.result;
552
+ const rawResult = result.data.toObject();
536
553
  return rawResult.map(row => ({
537
554
  ...row,
538
555
  cardinality: (0, row_data_utils_1.rowDataToNumber)(row.cardinality),
@@ -870,4 +887,18 @@ class ExploreMaterializer extends FluentState {
870
887
  }
871
888
  }
872
889
  exports.ExploreMaterializer = ExploreMaterializer;
890
+ /**
891
+ * Structural check for the `BuildManifest` shape: a non-null object with an
892
+ * object `entries` field. Doesn't validate every entry — `BuildManifestEntry`
893
+ * is just `{tableName: string}`, so a stricter walk could come later if we
894
+ * find malformed entries causing trouble. The current goal is to fail
895
+ * cleanly on a manifest file that parsed to a string/array/null instead of
896
+ * leaving a downstream `entries[buildId]` lookup to crash on `undefined`.
897
+ */
898
+ function isBuildManifestShape(value) {
899
+ if (typeof value !== 'object' || value === null)
900
+ return false;
901
+ const entries = value.entries;
902
+ return typeof entries === 'object' && entries !== null;
903
+ }
873
904
  //# sourceMappingURL=runtime.js.map
@@ -40,6 +40,7 @@ export declare abstract class BaseConnection implements Connection {
40
40
  canPersist(): this is PersistSQLResults;
41
41
  canStream(): this is StreamingConnection;
42
42
  close(): Promise<void>;
43
+ idle(): Promise<void>;
43
44
  estimateQueryCost(_sqlCommand: string): Promise<QueryRunStats>;
44
45
  fetchMetadata(): Promise<{}>;
45
46
  fetchTableMetadata(_tablePath: string): Promise<{}>;
@@ -78,6 +78,7 @@ class BaseConnection {
78
78
  return false;
79
79
  }
80
80
  async close() { }
81
+ async idle() { }
81
82
  async estimateQueryCost(_sqlCommand) {
82
83
  return {};
83
84
  }
@@ -122,11 +122,14 @@ export declare function readConnectionsConfig(jsonText: string): ConnectionsConf
122
122
  export declare function writeConnectionsConfig(config: ConnectionsConfig): string;
123
123
  /**
124
124
  * A LookupConnection with lifecycle management: close() shuts down all
125
- * cached connections, and an optional onConnectionCreated callback fires
126
- * once per connection after factory creation (before caching).
125
+ * cached connections, idle() releases their backend resources but keeps
126
+ * the cache so the same connection objects are reused on next lookup, and
127
+ * an optional onConnectionCreated callback fires once per connection after
128
+ * factory creation (before caching).
127
129
  */
128
130
  export interface ManagedConnectionLookup extends LookupConnection<Connection> {
129
131
  close(): Promise<void>;
132
+ idle(): Promise<void>;
130
133
  }
131
134
  /**
132
135
  * Create a ManagedConnectionLookup from a ConnectionsConfig using registered
@@ -159,6 +159,13 @@ function createConnectionsFromConfig(config, onConnectionCreated) {
159
159
  await conn.close();
160
160
  }
161
161
  },
162
+ async idle() {
163
+ // Cache is preserved — the same Connection objects are reused so that
164
+ // schema cache and other in-process state survive the idle.
165
+ for (const conn of cache.values()) {
166
+ await conn.idle();
167
+ }
168
+ },
162
169
  };
163
170
  }
164
171
  //# sourceMappingURL=registry.js.map
@@ -77,6 +77,20 @@ export interface Connection extends InfoConnection {
77
77
  canPersist(): this is PersistSQLResults;
78
78
  canStream(): this is StreamingConnection;
79
79
  close(): Promise<void>;
80
+ /**
81
+ * Release expensive backend resources (file locks, sockets, sub-processes,
82
+ * pooled connections) but remain logically valid. The next operation
83
+ * transparently reattaches whatever was released; schema cache and other
84
+ * in-process state survive.
85
+ *
86
+ * The default is a no-op for backends that hold no release-able resources
87
+ * between operations. Backends that hold OS-level resources (DuckDB file
88
+ * locks, persistent socket pools) should override.
89
+ *
90
+ * Hosts that share a connection across concurrent operations should not
91
+ * call `idle()` while an operation is in flight.
92
+ */
93
+ idle(): Promise<void>;
80
94
  estimateQueryCost(sqlCommand: string): Promise<QueryRunStats>;
81
95
  fetchMetadata: () => Promise<ConnectionMetadata>;
82
96
  fetchTableMetadata: (tablePath: string) => Promise<TableMetadata>;
@@ -6,9 +6,13 @@
6
6
  * RULE: BLOCK BODY
7
7
  *
8
8
  * A `{ … }` body containing statements (extend body, view body, etc.). Walks
9
- * children; between adjacent statements, preserves a single user-supplied
10
- * blank line *only if the kinds differ*. Same-kind adjacent statements never
11
- * get a blank.
9
+ * children; between adjacent statements:
10
+ * - `view:` definitions always get a blank line before them (and after
11
+ * the previous one), regardless of whether the source had a blank or
12
+ * what kind preceded — view definitions read as their own sections.
13
+ * - For other kinds: preserve a single user-supplied blank line only if
14
+ * the kinds differ. Same-kind adjacent statements (consecutive
15
+ * dimensions, measures, etc.) never get a blank.
12
16
  *
13
17
  * Also: top-level body — forces a blank line before each statement after the
14
18
  * first, regardless of source spacing (top-level statements should breathe).
@@ -61,9 +65,18 @@ function formatBlockBody(f, ctx) {
61
65
  if (c instanceof antlr4ts_1.ParserRuleContext) {
62
66
  if (lastChild !== null) {
63
67
  const userHadBlank = c._start.line - lastChildEndLine > 1;
64
- const sameKind = statementKind(lastChild) === statementKind(c);
65
- if (userHadBlank && !sameKind)
68
+ const lastKind = statementKind(lastChild);
69
+ const curKind = statementKind(c);
70
+ const sameKind = lastKind === curKind;
71
+ // `view:` definitions always breathe — blank line above each one
72
+ // (and after the previous one) regardless of the user's source
73
+ // spacing or what kind preceded.
74
+ if (curKind === 'view' || lastKind === 'view') {
66
75
  f.o.blank();
76
+ }
77
+ else if (userHadBlank && !sameKind) {
78
+ f.o.blank();
79
+ }
67
80
  }
68
81
  f.format(c);
69
82
  lastChild = c;
@@ -60,6 +60,7 @@ const sections_1 = require("./sections");
60
60
  const field_properties_1 = require("./field-properties");
61
61
  const pick_case_1 = require("./pick-case");
62
62
  const binary_chain_1 = require("./binary-chain");
63
+ const import_select_1 = require("./import-select");
63
64
  class Formatter {
64
65
  constructor(src, tokens) {
65
66
  this.src = src;
@@ -123,6 +124,10 @@ class Formatter {
123
124
  return (0, sections_1.formatSectionStatement)(this, node, rule);
124
125
  }
125
126
  }
127
+ // RULE: IMPORT SELECT — `import {a, b} from 'x'` stays compact.
128
+ if (node instanceof parser.ImportSelectContext) {
129
+ return (0, import_select_1.formatImportSelect)(this, node);
130
+ }
126
131
  // RULE: PICK / CASE / BINARY CHAIN.
127
132
  if (node instanceof parser.PickStatementContext)
128
133
  return (0, pick_case_1.formatPickStatement)(this, node);
@@ -0,0 +1,3 @@
1
+ import type * as parser from '../lib/Malloy/MalloyParser';
2
+ import type { Formatter } from './formatter';
3
+ export declare function formatImportSelect(f: Formatter, ctx: parser.ImportSelectContext): void;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright Contributors to the Malloy project
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * RULE: IMPORT SELECT — `import {a, b, c} from 'url'`
7
+ *
8
+ * The selection list is `{ id (IS id)? (, id (IS id)?)* }`. Inline if the
9
+ * whole brace-and-contents fits on the current line. Otherwise wrap with
10
+ * each item on its own line at +1 indent.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.formatImportSelect = formatImportSelect;
14
+ const tokens_1 = require("./tokens");
15
+ const leaf_1 = require("./leaf");
16
+ const inline_renderer_1 = require("./inline-renderer");
17
+ function formatImportSelect(f, ctx) {
18
+ // Flush hidden tokens between the previous emit (IMPORT) and our opener
19
+ // so a comment like `import /* tag */ {a} from 'x'` is preserved.
20
+ (0, leaf_1.flushHiddenBefore)(f, ctx._start.tokenIndex);
21
+ const items = ctx.importItem();
22
+ // Grammar puts FROM as the last token of importSelect: emit it ourselves
23
+ // so the trailing space and lastEmittedIdx land correctly.
24
+ const fromTok = ctx.FROM().symbol;
25
+ const fromIdx = fromTok.tokenIndex;
26
+ if (items.length === 0) {
27
+ f.o.space();
28
+ f.o.text('{} from');
29
+ (0, leaf_1.note)(f, tokens_1.L.FROM, fromIdx, fromTok);
30
+ return;
31
+ }
32
+ const firstItem = items[0];
33
+ const lastItem = items[items.length - 1];
34
+ // Comments anywhere in the items' span (between items, inside an `as is`
35
+ // form, etc.) get stripped by renderItemInline. Fall back to a comment-
36
+ // safe wrap that emits each item via f.format — the leaf walker handles
37
+ // hidden-channel placement.
38
+ const itemsHaveComments = (0, leaf_1.hasCommentsInRange)(f, firstItem._start.tokenIndex, lastItem._stop.tokenIndex);
39
+ if (!itemsHaveComments) {
40
+ const itemStrs = items.map(it => (0, inline_renderer_1.renderItemInline)(f, it));
41
+ const inlineBody = '{' + itemStrs.join(', ') + '} from';
42
+ if (f.o.lineLengthSoFar() + 1 + inlineBody.length <= tokens_1.LINE_BUDGET) {
43
+ f.o.space();
44
+ f.o.text(inlineBody);
45
+ (0, leaf_1.note)(f, tokens_1.L.FROM, fromIdx, fromTok);
46
+ return;
47
+ }
48
+ // Wrap form (no comments): one item per line at +1 indent. Pre-rendered
49
+ // text is fine because renderItemInline saw no comments to drop.
50
+ f.o.space();
51
+ f.o.text('{');
52
+ f.o.indent++;
53
+ for (let i = 0; i < itemStrs.length; i++) {
54
+ f.o.nl();
55
+ f.o.text(itemStrs[i]);
56
+ if (i < itemStrs.length - 1)
57
+ f.o.text(',');
58
+ }
59
+ f.o.indent--;
60
+ f.o.nl();
61
+ f.o.text('} from');
62
+ (0, leaf_1.note)(f, tokens_1.L.FROM, fromIdx, fromTok);
63
+ return;
64
+ }
65
+ // Comment-safe wrap: each item emits via f.format so flushHiddenBefore
66
+ // can place its leading/inter-item comments correctly. Trailing `,` after
67
+ // each non-last item, leaf walker turns it into a newline at indent.
68
+ f.o.space();
69
+ f.o.text('{');
70
+ f.o.indent++;
71
+ // Advance past the OCURLY we just emitted manually so the first item's
72
+ // flushHiddenBefore doesn't try to re-emit it.
73
+ f.lastEmittedIdx = ctx._start.tokenIndex;
74
+ for (let i = 0; i < items.length; i++) {
75
+ (0, leaf_1.flushHiddenBefore)(f, items[i]._start.tokenIndex);
76
+ f.o.nl();
77
+ f.format(items[i]);
78
+ if (i < items.length - 1)
79
+ f.o.text(',');
80
+ }
81
+ // Catch any tail comments between the last item and the closing `}`.
82
+ (0, leaf_1.flushHiddenBefore)(f, fromIdx);
83
+ f.o.indent--;
84
+ f.o.nl();
85
+ f.o.text('} from');
86
+ (0, leaf_1.note)(f, tokens_1.L.FROM, fromIdx, fromTok);
87
+ }
88
+ //# sourceMappingURL=import-select.js.map
@@ -57,6 +57,30 @@
57
57
  * - Single-arg function calls don't wrap (no point — nowhere useful to break).
58
58
  * - `(` hugs only after a known-callable token (CALL_HUG_AFTER); after `is`,
59
59
  * `as`, `extend`, `on`, `when`, etc. the `(` is grouping and gets a space.
60
+ * CALL_HUG_AFTER includes the keyword-named built-ins that are commonly
61
+ * used as functions: ALL, EXCLUDE, the timeframe truncation keywords
62
+ * (YEAR/MONTH/DAY/…), and the cast-target type names
63
+ * (TIMESTAMP/DATE/NUMBER/STRING/BOOLEAN/JSON).
64
+ * - `!` is the cast operator (`epoch_ms!timestamp(x)`); it glues to both
65
+ * sides like `.` does.
66
+ * - Empty `{}` collapses inline (`extend {}`, not `extend {\n}`).
67
+ * - `import {a, b, c} from 'x'` formats as a flat list when it fits on the
68
+ * line; otherwise one item per line at +1 indent.
69
+ * - `view:` definitions in a block body always have a blank line before
70
+ * each one (after the first), even when no blank was in the source. The
71
+ * same-kind-no-blank rule still applies to other statement kinds.
72
+ * - `{...}` bodies that contain more than one section statement never
73
+ * collapse onto a single line, even when they would fit. Reading two
74
+ * `group_by:` clauses jammed on one line is hostile.
75
+ * - Single is-item section lists keep the keyword and item on the same
76
+ * line (`nest: name is { … }`), so the body wraps naturally instead of
77
+ * forcing a `nest:\n name is {` opener split.
78
+ * - join_one / join_many / join_cross multi-item lists always wrap one
79
+ * item per line — items use `with`/`on` instead of `is` but are
80
+ * structurally is-like.
81
+ * - Trailing comments between the last item of a section list and the
82
+ * enclosing `}` are emitted at the section's inner indent, so they
83
+ * stay associated with the section the user wrote them in.
60
84
  *
61
85
  * Adding a new section-statement
62
86
  * ------------------------------
@@ -127,6 +127,18 @@ function hasCommentsInRange(f, fromIdx, toIdx) {
127
127
  }
128
128
  return false;
129
129
  }
130
+ // Index of the next non-hidden, non-EOF token strictly after `idx`, or -1.
131
+ function nextVisibleAfter(f, idx) {
132
+ for (let j = idx + 1; j < f.tokens.length; j++) {
133
+ const t = f.tokens[j];
134
+ if (t.channel === antlr4ts_1.Token.HIDDEN_CHANNEL)
135
+ continue;
136
+ if (t.type === antlr4ts_1.Token.EOF)
137
+ return -1;
138
+ return j;
139
+ }
140
+ return -1;
141
+ }
130
142
  // Does the paren-pair at [openIdx, closeIdx] have any COMMA at its own depth?
131
143
  // (Used to distinguish "function call with multiple args" from "single-arg
132
144
  // call" / "empty parens".)
@@ -190,6 +202,21 @@ function emitVisibleToken(f, t, idx) {
190
202
  if (t.type === tokens_1.L.OCURLY) {
191
203
  f.o.space();
192
204
  f.o.text('{');
205
+ // Empty `{}`: peek the next visible token. If it's the matching close
206
+ // AND nothing hidden sits between them (no comments to preserve), emit
207
+ // inline so we get `extend {}` not `extend {\n}`. With a comment in the
208
+ // gap (`extend { /* keep */ }`), fall through to the wrapping form so
209
+ // the leaf walker's comment placement runs.
210
+ const nextVisible = nextVisibleAfter(f, idx);
211
+ if (nextVisible !== -1 &&
212
+ f.tokens[nextVisible].type === tokens_1.L.CCURLY &&
213
+ !hasCommentsInRange(f, idx + 1, nextVisible - 1)) {
214
+ f.o.text('}');
215
+ if (f.o.indent === 0)
216
+ f.needBlank = true;
217
+ note(f, tokens_1.L.CCURLY, nextVisible, f.tokens[nextVisible]);
218
+ return;
219
+ }
193
220
  f.o.indent++;
194
221
  f.o.nl();
195
222
  note(f, t.type, idx, t);
@@ -1,5 +1,5 @@
1
1
  import type { ParserRuleContext } from 'antlr4ts';
2
- export type ItemKind = 'fieldEntry' | 'nestEntry' | 'fieldDef' | 'fieldName' | 'collectionMember' | 'orderBySpec' | 'fieldExpr';
2
+ export type ItemKind = 'fieldEntry' | 'nestEntry' | 'fieldDef' | 'fieldName' | 'collectionMember' | 'orderBySpec' | 'fieldExpr' | 'joinDef' | 'includeField' | 'indexElement';
3
3
  export interface SectionRule {
4
4
  ctxClass: new (...args: never[]) => ParserRuleContext;
5
5
  keywordTypes: number[];
@@ -79,6 +79,14 @@ exports.SECTION_STATEMENT_RULES = [
79
79
  rule(parser.OrderByStatementContext, [tokens_1.L.ORDER_BY], c => c.ordering(), 'orderBySpec'),
80
80
  rule(parser.WhereStatementContext, [tokens_1.L.WHERE], c => c.filterClauseList(), 'fieldExpr'),
81
81
  rule(parser.HavingStatementContext, [tokens_1.L.HAVING], c => c.filterClauseList(), 'fieldExpr'),
82
+ rule(parser.DefJoinOneContext, [tokens_1.L.JOIN_ONE], c => c.joinList(), 'joinDef'),
83
+ rule(parser.DefJoinManyContext, [tokens_1.L.JOIN_MANY], c => c.joinList(), 'joinDef'),
84
+ rule(parser.DefJoinCrossContext, [tokens_1.L.JOIN_CROSS], c => c.joinList(), 'joinDef'),
85
+ // include block items: `public: a, b`, `internal: x, y, z`. The keyword
86
+ // (PUBLIC/PRIVATE/INTERNAL) lives one level down inside accessLabelProp;
87
+ // findKeyword in ./sections handles that nested case.
88
+ rule(parser.IncludeItemContext, [tokens_1.L.PUBLIC, tokens_1.L.PRIVATE, tokens_1.L.INTERNAL], c => c.includeList(), 'includeField'),
89
+ rule(parser.IndexStatementContext, [tokens_1.L.INDEX], c => c.indexFields(), 'indexElement'),
82
90
  ];
83
91
  // Coarse statement-kind labels for the same-kind-no-blank rule in block
84
92
  // bodies. Different kinds preserve a single user-supplied blank line.
@@ -13,11 +13,21 @@
13
13
  *
14
14
  * formatSectionList rule (locked in with the user):
15
15
  * - All bare items + total fits ≤ LINE_BUDGET → inline `kw: a, b, c`.
16
- * - Single item that fits (even if it has `is`) → inline.
16
+ * - Single item that fits (even if it has `is`) → inline. Items containing
17
+ * a `{...}` body with more than one section statement are excluded —
18
+ * view bodies don't read on one line regardless of length.
19
+ * - Single is-item that doesn't fit inline → keep keyword and item on the
20
+ * same line (`nest: name is { …wrapped body… }`); the body's `{...}`
21
+ * wraps internally. Annotated items still take the keyword-on-own-line
22
+ * form so the annotation lands above its item.
17
23
  * - Otherwise → wrapped: keyword on its own line; items at +1 indent;
18
24
  * bare items flow-fill ≤ LINE_BUDGET, comma-separated intra-line,
19
25
  * no trailing commas; `is` items each on own line; annotated items
20
26
  * each on own line, annotation on the line above.
27
+ *
28
+ * After the last item, trailing comments that sit between the section and
29
+ * the enclosing `}` are also emitted at the inner indent — they belong to
30
+ * the section the user just wrote, not the parent block.
21
31
  */
22
32
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
33
  if (k2 === undefined) k2 = k;
@@ -70,7 +80,9 @@ function formatSectionStatement(f, stmt, rule) {
70
80
  }
71
81
  for (let i = 0; i < stmt.childCount; i++) {
72
82
  const c = stmt.getChild(i);
73
- if (c instanceof tree_1.TerminalNode && c.symbol === keywordTok) {
83
+ const isKeywordChild = (c instanceof tree_1.TerminalNode && c.symbol === keywordTok) ||
84
+ (c instanceof antlr4ts_1.ParserRuleContext && childContainsToken(c, keywordTok));
85
+ if (isKeywordChild) {
74
86
  (0, leaf_1.flushHiddenBefore)(f, keywordTok.tokenIndex);
75
87
  if (f.o.indent > 0)
76
88
  f.o.nl();
@@ -83,18 +95,39 @@ function formatSectionStatement(f, stmt, rule) {
83
95
  if (c === listCtx) {
84
96
  const items = listItems(listCtx, rule.itemKind);
85
97
  if (items.length > 0)
86
- formatSectionList(f, items);
98
+ formatSectionList(f, items, rule.itemKind);
87
99
  continue;
88
100
  }
89
101
  f.format(c);
90
102
  }
91
103
  }
104
+ function childContainsToken(node, tok) {
105
+ for (let i = 0; i < node.childCount; i++) {
106
+ const c = node.getChild(i);
107
+ if (c instanceof tree_1.TerminalNode && c.symbol === tok)
108
+ return true;
109
+ }
110
+ return false;
111
+ }
92
112
  function findKeyword(node, types) {
113
+ // Direct terminal children — fast path, the common case.
93
114
  for (let i = 0; i < node.childCount; i++) {
94
115
  const c = node.getChild(i);
95
116
  if (c instanceof tree_1.TerminalNode && types.includes(c.symbol.type))
96
117
  return c.symbol;
97
118
  }
119
+ // Fallback: descend one level. Some rules wrap the keyword in a small
120
+ // nested rule (e.g. includeItem → accessLabelProp → INTERNAL).
121
+ for (let i = 0; i < node.childCount; i++) {
122
+ const c = node.getChild(i);
123
+ if (c instanceof antlr4ts_1.ParserRuleContext) {
124
+ for (let j = 0; j < c.childCount; j++) {
125
+ const g = c.getChild(j);
126
+ if (g instanceof tree_1.TerminalNode && types.includes(g.symbol.type))
127
+ return g.symbol;
128
+ }
129
+ }
130
+ }
98
131
  return undefined;
99
132
  }
100
133
  function listItems(listCtx, itemKind) {
@@ -117,6 +150,12 @@ function listItems(listCtx, itemKind) {
117
150
  return c instanceof parser.OrderBySpecContext;
118
151
  case 'fieldExpr':
119
152
  return c instanceof parser.FieldExprContext;
153
+ case 'joinDef':
154
+ return c instanceof parser.JoinDefContext;
155
+ case 'includeField':
156
+ return c instanceof parser.IncludeFieldContext;
157
+ case 'indexElement':
158
+ return c instanceof parser.IndexElementContext;
120
159
  }
121
160
  };
122
161
  const out = [];
@@ -127,8 +166,10 @@ function listItems(listCtx, itemKind) {
127
166
  }
128
167
  return out;
129
168
  }
130
- function classifyItem(f, ctx) {
131
- let hasIs = false;
169
+ function classifyItem(f, ctx, itemKind) {
170
+ // joinDef items always wrap onto their own line. They use `with` / `on`
171
+ // instead of `is`, but they're structurally one-per-line like is-items.
172
+ let hasIs = itemKind === 'joinDef';
132
173
  let hasAnnotation = false;
133
174
  for (let i = ctx._start.tokenIndex; i <= ctx._stop.tokenIndex; i++) {
134
175
  const t = f.tokens[i];
@@ -142,17 +183,23 @@ function classifyItem(f, ctx) {
142
183
  }
143
184
  return { ctx, hasIs, hasAnnotation };
144
185
  }
145
- function formatSectionList(f, items) {
146
- const itemInfos = items.map(it => classifyItem(f, it));
186
+ function formatSectionList(f, items, itemKind) {
187
+ const itemInfos = items.map(it => classifyItem(f, it, itemKind));
147
188
  const noAnnotations = itemInfos.every(info => !info.hasAnnotation);
148
189
  const allBare = itemInfos.every(info => !info.hasIs && !info.hasAnnotation);
149
190
  const firstItem = items[0];
150
191
  const lastItem = items[items.length - 1];
151
192
  // Inline candidate: no annotations, no hidden-channel comments anywhere in
152
193
  // the items' span (renderItemInline drops them), AND either all bare or
153
- // exactly one item.
194
+ // exactly one item. Items containing a `{...}` body with multiple inner
195
+ // statements are also excluded — collapsing a view body onto one line is
196
+ // hostile to read regardless of length.
154
197
  const itemsHaveComments = (0, leaf_1.hasCommentsInRange)(f, firstItem._start.tokenIndex, lastItem._stop.tokenIndex);
155
- const inlineEligible = noAnnotations && !itemsHaveComments && (allBare || items.length === 1);
198
+ const itemsHaveMultiStatementBody = itemInfos.some(info => hasMultiStatementCurlyBody(f, info.ctx));
199
+ const inlineEligible = noAnnotations &&
200
+ !itemsHaveComments &&
201
+ !itemsHaveMultiStatementBody &&
202
+ (allBare || items.length === 1);
156
203
  if (inlineEligible) {
157
204
  const renderedItems = itemInfos.map(info => (0, inline_renderer_1.renderItemInline)(f, info.ctx));
158
205
  const inlineBody = renderedItems.join(', ');
@@ -164,6 +211,20 @@ function formatSectionList(f, items) {
164
211
  return;
165
212
  }
166
213
  }
214
+ // Single is-item that doesn't fit inline: keep the keyword on the same
215
+ // line as the item (`nest: name is { …wrapped body… }`) instead of
216
+ // breaking before the name. The body's `{...}` will wrap on its own.
217
+ // Annotated items still need the keyword-on-own-line form so the
218
+ // annotation can land between them.
219
+ if (items.length === 1 &&
220
+ itemInfos[0].hasIs &&
221
+ !itemInfos[0].hasAnnotation &&
222
+ !itemsHaveComments) {
223
+ f.o.text(' ');
224
+ f.format(itemInfos[0].ctx);
225
+ f.lastEmittedType = lastItem._stop.type;
226
+ return;
227
+ }
167
228
  // Wrapped form. Two paths:
168
229
  // - No comments anywhere: original flow-fill — bare items pack into lines
169
230
  // at LINE_BUDGET, `is`/annotated items each get their own line.
@@ -230,14 +291,56 @@ function formatSectionList(f, items) {
230
291
  // lastEmittedIdx so tail comments aren't re-emitted by the parent.
231
292
  f.lastEmittedType = lastItem._stop.type;
232
293
  }
233
- // After the last item of a wrapped section list, flush any trailing comments
234
- // on the SAME source line as the last item those are tail comments belong-
235
- // ing to the last item and should emit at the section's inner indent.
236
- // Different-line comments are leading comments for the next statement; leave
237
- // them for the parent context to emit at the outer indent.
294
+ // Does any `{...}` block inside this item contain more than one section
295
+ // keyword (group_by:, aggregate:, where:, …) at its top level? Used to gate
296
+ // the section-list inline form: a view body with multiple statements never
297
+ // reads well on a single line, even when it fits. Single-statement bodies
298
+ // like `{ where: x = 1 }` may still inline.
299
+ function hasMultiStatementCurlyBody(f, ctx) {
300
+ const fromIdx = ctx._start.tokenIndex;
301
+ const toIdx = ctx._stop.tokenIndex;
302
+ for (let i = fromIdx; i <= toIdx; i++) {
303
+ if (f.tokens[i].type !== tokens_1.L.OCURLY)
304
+ continue;
305
+ const close = (0, tokens_1.findMatching)(f.tokens, i, tokens_1.L.OCURLY, tokens_1.L.CCURLY);
306
+ let count = 0;
307
+ let depth = 0;
308
+ for (let j = i + 1; j < close; j++) {
309
+ const t = f.tokens[j];
310
+ if (t.type === tokens_1.L.OCURLY || t.type === tokens_1.L.OPAREN || t.type === tokens_1.L.OBRACK) {
311
+ depth++;
312
+ }
313
+ else if (t.type === tokens_1.L.CCURLY ||
314
+ t.type === tokens_1.L.CPAREN ||
315
+ t.type === tokens_1.L.CBRACK) {
316
+ depth--;
317
+ }
318
+ else if (depth === 0 && tokens_1.SECTION_TOKENS.has(t.type)) {
319
+ count++;
320
+ if (count > 1)
321
+ return true;
322
+ }
323
+ }
324
+ i = close;
325
+ }
326
+ return false;
327
+ }
328
+ // After the last item of a wrapped section list, flush trailing comments
329
+ // belonging to the section. There are two cases:
330
+ //
331
+ // 1. Same-line tail: a comment on the SAME source line as the last item.
332
+ // Always belongs to the last item; emit at the section's inner indent.
333
+ //
334
+ // 2. Different-line trailing comments that sit between the last item and
335
+ // the closing `}` of the enclosing block (no other statement follows).
336
+ // These visually belong to the section the user just wrote, not the
337
+ // block. Emit them at the inner indent so they stay associated with
338
+ // the section. If a real statement follows the comments, leave them
339
+ // for the parent — they're leading comments for that statement.
238
340
  function flushSameLineTail(f, lastTok) {
239
341
  const lastEndLine = (0, tokens_1.endLineOf)(lastTok);
240
342
  let j = lastTok.tokenIndex + 1;
343
+ // Phase 1: same-line tail comments.
241
344
  while (j < f.tokens.length) {
242
345
  const t = f.tokens[j];
243
346
  if (t.channel !== antlr4ts_1.Token.HIDDEN_CHANNEL)
@@ -247,15 +350,31 @@ function flushSameLineTail(f, lastTok) {
247
350
  j++;
248
351
  }
249
352
  if (j > lastTok.tokenIndex + 1) {
250
- // The wrapping loop emitted a per-item newline after the last item, but
251
- // a same-line tail comment should attach to that item's line — not float
252
- // on a fresh one. Drop the trailing newline so emitHiddenToken's
253
- // same-line branch reattaches the comment correctly (and adds a trailing
254
- // newline back for EOL comments). Without this, the comment lands on a
255
- // new line, and a re-parse sees it as a different-line comment, breaking
256
- // idempotence.
353
+ // Same-line comments: drop the wrapping loop's per-item newline so
354
+ // emitHiddenToken's same-line branch reattaches the comment correctly
355
+ // (and adds a trailing newline back for EOL comments). Otherwise a
356
+ // re-parse sees a different-line comment, breaking idempotence.
257
357
  f.o.trimTrailingNewlines();
258
358
  (0, leaf_1.flushHiddenBefore)(f, j);
259
359
  }
360
+ // Phase 2: own-line comments before the next visible token. If the next
361
+ // visible token is a closing `}`, the comments visually belong to this
362
+ // section — emit them at the inner indent. Otherwise leave them for the
363
+ // parent (they're leading comments for whatever follows).
364
+ let k = j;
365
+ let trailingHidden = 0;
366
+ while (k < f.tokens.length) {
367
+ const t = f.tokens[k];
368
+ if (t.channel !== antlr4ts_1.Token.HIDDEN_CHANNEL)
369
+ break;
370
+ trailingHidden++;
371
+ k++;
372
+ }
373
+ if (trailingHidden > 0 && k < f.tokens.length) {
374
+ const next = f.tokens[k];
375
+ if (next.type === tokens_1.L.CCURLY) {
376
+ (0, leaf_1.flushHiddenBefore)(f, k);
377
+ }
378
+ }
260
379
  }
261
380
  //# sourceMappingURL=sections.js.map
@@ -87,6 +87,26 @@ exports.CALL_HUG_AFTER = new Set([
87
87
  exports.L.CAST,
88
88
  exports.L.NOW,
89
89
  exports.L.LAST,
90
+ // Ungrouped / level-modifier function-style calls.
91
+ exports.L.ALL,
92
+ exports.L.EXCLUDE,
93
+ // Timeframe truncation keywords used as functions: year(x), month(x), …
94
+ exports.L.YEAR,
95
+ exports.L.QUARTER,
96
+ exports.L.MONTH,
97
+ exports.L.WEEK,
98
+ exports.L.DAY,
99
+ exports.L.HOUR,
100
+ exports.L.MINUTE,
101
+ exports.L.SECOND,
102
+ // Type-cast keyword names: timestamp(x), date(x), number(x), string(x), …
103
+ exports.L.TIMESTAMP,
104
+ exports.L.TIMESTAMPTZ,
105
+ exports.L.DATE,
106
+ exports.L.NUMBER,
107
+ exports.L.STRING,
108
+ exports.L.BOOLEAN,
109
+ exports.L.JSON,
90
110
  ]);
91
111
  // Binary operators that get spaces on both sides at the leaf level.
92
112
  // (Chain wrapping for and/or/??/+/- happens at parse-tree level — see
@@ -119,10 +139,15 @@ function leadingAction(prevType, nextType) {
119
139
  nextType === exports.L.SEMI ||
120
140
  nextType === exports.L.COLON ||
121
141
  nextType === exports.L.TRIPLECOLON ||
142
+ nextType === exports.L.EXCLAM ||
122
143
  nextType === exports.L.CPAREN ||
123
144
  nextType === exports.L.CBRACK) {
124
145
  return 'glue';
125
146
  }
147
+ // After the `!` cast operator (`epoch_ms!timestamp(x)`), the next token
148
+ // glues to it like the `.` operator does.
149
+ if (prevType === exports.L.EXCLAM)
150
+ return 'glue';
126
151
  if ((nextType === exports.L.OPAREN || nextType === exports.L.OBRACK) &&
127
152
  prevType !== null &&
128
153
  exports.CALL_HUG_AFTER.has(prevType)) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const MALLOY_VERSION = "0.0.386";
1
+ export declare const MALLOY_VERSION = "0.0.388";
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MALLOY_VERSION = void 0;
4
4
  // generated with 'generate-version-file' script; do not edit manually
5
- exports.MALLOY_VERSION = '0.0.386';
5
+ exports.MALLOY_VERSION = '0.0.388';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloy",
3
- "version": "0.0.386",
3
+ "version": "0.0.388",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -51,9 +51,9 @@
51
51
  "generate-version-file": "VERSION=$(npm pkg get version --workspaces=false | tr -d \\\")\necho \"// generated with 'generate-version-file' script; do not edit manually\\nexport const MALLOY_VERSION = '$VERSION';\" > src/version.ts"
52
52
  },
53
53
  "dependencies": {
54
- "@malloydata/malloy-filter": "0.0.386",
55
- "@malloydata/malloy-interfaces": "0.0.386",
56
- "@malloydata/malloy-tag": "0.0.386",
54
+ "@malloydata/malloy-filter": "0.0.388",
55
+ "@malloydata/malloy-interfaces": "0.0.388",
56
+ "@malloydata/malloy-tag": "0.0.388",
57
57
  "@noble/hashes": "^1.8.0",
58
58
  "antlr4ts": "^0.5.0-alpha.4",
59
59
  "assert": "^2.0.0",