@malloydata/malloy 0.0.386 → 0.0.387

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.
@@ -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.387";
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.387';
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.387",
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.387",
55
+ "@malloydata/malloy-interfaces": "0.0.387",
56
+ "@malloydata/malloy-tag": "0.0.387",
57
57
  "@noble/hashes": "^1.8.0",
58
58
  "antlr4ts": "^0.5.0-alpha.4",
59
59
  "assert": "^2.0.0",