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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
# Gitleaks: scan staged diffs for PII/internal references before they reach OS
if ! command -v gitleaks &> /dev/null; then
echo "ERROR: gitleaks is not installed. Install it: brew install gitleaks"
exit 1
fi
gitleaks git --staged --no-banner -c "$(git rev-parse --show-toplevel)/.gitleaks.toml"

# Run lint-staged for code quality
npx lint-staged

Expand Down
34 changes: 31 additions & 3 deletions packages/language/src/core/analysis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ export {
each,
defineRule,
schemaContextKey,
} from './lint.js';
} from './lint-engine.js';

export type { LintPass, StoreKey, EachDep, Dep, ResolveDeps } from './lint.js';
export type {
LintPass,
StoreKey,
EachDep,
Dep,
ResolveDeps,
} from './lint-engine.js';

export {
createSchemaContext,
Expand All @@ -32,7 +38,13 @@ export type { DocumentSymbol } from './symbols.js';

export { resolveReference, walkDefinitionKeys } from './references.js';

export { collectDiagnostics } from './ast-walkers.js';
export {
collectDiagnostics,
recurseAstChildren,
walkAstExpressions,
dispatchAstChildren,
forEachExpressionChild,
} from './ast-walkers.js';

export {
resolveSchemaField,
Expand All @@ -53,3 +65,19 @@ export type {
KeywordHover,
HoverResult,
} from './hover-resolver.js';

export {
positionIndexKey,
queryExpressionAtPosition,
queryDefinitionAtPosition,
queryScopeAtPosition,
} from './position-index.js';

export type { PositionIndex } from './position-index.js';

export { positionIndexPass } from './position-index-pass.js';

export { symbolTableAnalyzer, symbolTableKey } from './symbol-table.js';

export { walkSchema } from './schema-walker.js';
export type { SchemaFieldVisitor } from './schema-walker.js';
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
* For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0
*/

import type { AstRoot, AstNodeLike } from '../core/types.js';
import { isNamedMap, isAstNodeLike } from '../core/types.js';
import type { ScopeContext } from '../core/analysis/scope.js';
import type { LintPass, PassStore } from '../core/analysis/lint.js';
import { walkDefinitionKeys } from '../core/analysis/references.js';
import { recurseAstChildren } from '../core/analysis/ast-walkers.js';
import type { AstRoot, AstNodeLike } from '../types.js';
import { isNamedMap, isAstNodeLike } from '../types.js';
import type { ScopeContext } from './scope.js';
import type { LintPass, PassStore } from './lint-engine.js';
import { walkDefinitionKeys } from './references.js';
import { recurseAstChildren } from './ast-walkers.js';
import {
positionIndexKey,
type ExpressionEntry,
type DefinitionEntry,
type ScopeEntry,
} from '../core/analysis/position-index.js';
} from './position-index.js';

class PositionIndexPass implements LintPass {
readonly id = positionIndexKey;
Expand Down
2 changes: 1 addition & 1 deletion packages/language/src/core/analysis/position-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import type { Range, AstNodeLike } from '../types.js';
import type { ScopeContext } from './scope.js';
import { isPositionInRange, rangeSize } from './ast-utils.js';
import { storeKey } from './lint.js';
import { storeKey } from './lint-engine.js';

export interface ExpressionEntry {
expr: AstNodeLike;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@
* For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0
*/

import type { AstRoot, FieldType, AstNodeLike, Schema } from '../core/types.js';
import type { AstRoot, FieldType, AstNodeLike, Schema } from '../types.js';
import {
astField,
isNamedMap,
isAstNodeLike,
isCollectionFieldType,
extractDiscriminantValue,
hasDiscriminant,
} from '../core/types.js';
import { SequenceNode } from '../core/sequence.js';
} from '../types.js';
import { SequenceNode } from '../sequence.js';

export interface SchemaFieldVisitor {
/** Called for each schema field within a block instance. `value` is undefined when the field is absent. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,9 @@
* For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0
*/

import type { AstRoot } from '../core/types.js';
import {
storeKey,
type LintPass,
type PassStore,
getDocumentSymbols,
type DocumentSymbol,
} from '../core/analysis/index.js';
import type { AstRoot } from '../types.js';
import { storeKey, type LintPass, type PassStore } from './lint-engine.js';
import { getDocumentSymbols, type DocumentSymbol } from './symbols.js';

export const symbolTableKey = storeKey<DocumentSymbol[]>('symbol-table');

Expand Down
2 changes: 1 addition & 1 deletion packages/language/src/core/block-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function Block<T extends Schema>(
}
}

const base = addBuilderMethods(BlockNode);
const base = addBuilderMethods(BlockNode, undefined, { factory: true });
if (options?.description) {
Object.defineProperty(base, '__metadata', {
value: { description: options.description },
Expand Down
4 changes: 3 additions & 1 deletion packages/language/src/core/collection-block-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ export function CollectionBlock<
}
}

const base = addBuilderMethods(CollectionBlockNode);
const base = addBuilderMethods(CollectionBlockNode, undefined, {
factory: true,
});
const dp = (key: string, value: unknown) =>
Object.defineProperty(base, key, {
value,
Expand Down
127 changes: 126 additions & 1 deletion packages/language/src/core/comment-attacher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,31 @@ import type {
Comment,
CommentAttachment,
CommentTarget,
Range,
SyntaxNode,
} from './types.js';
import { parseCommentNode } from './types.js';
import { isNamedMap, parseCommentNode } from './types.js';
import { ErrorBlock } from './children.js';
import type { Statement } from './statements.js';

/** A comment that has source-location range info (i.e. was parsed from source). */
type RangedComment = Comment & { range: Range };

function hasRange(c: Comment): c is RangedComment {
return c.range !== undefined;
}

/** Type guard for values with a `statements` array (e.g., ProcedureValueNode). */
function hasProcedureStatements(
value: unknown
): value is { statements: Statement[] } {
return (
value != null &&
typeof value === 'object' &&
'statements' in value &&
Array.isArray((value as { statements: unknown }).statements)
);
}

export class CommentAttacher {
private _pending: Comment[] = [];
Expand Down Expand Up @@ -152,3 +173,107 @@ export function attach(
if (!node || comments.length === 0) return;
node.__comments = [...(node.__comments ?? []), ...comments];
}

// ---------------------------------------------------------------------------
// Comment routing helpers — called from Dialect.parseMappingElements and
// parseSingularField to classify and place block-level comments relative to
// a parsed value's body.
// ---------------------------------------------------------------------------

/** Extract comments on the same line as an element (inline comments). */
export function parseInlineComments(element: SyntaxNode): Comment[] {
return element.children
.filter(c => c.type === 'comment' && c.startRow === element.startRow)
.map(c => parseCommentNode(c, 'inline'));
}

/** Extract all comment children from an element. */
export function parseElementComments(element: SyntaxNode): Comment[] {
return element.children
.filter(c => c.type === 'comment')
.map(c => parseCommentNode(c));
}

/**
* Split comments into those before and after a value node's range.
*
* Comments without source range info (programmatic comments) are always
* placed in `beforeBody`. The `afterBody` array is guaranteed to contain
* only comments with range info, since only comments whose source line
* falls after the value node can land there.
*/
export function splitContainerComments(
comments: Comment[],
valueNode: SyntaxNode | null
): { beforeBody: Comment[]; afterBody: RangedComment[] } {
if (!valueNode) {
return { beforeBody: comments, afterBody: [] };
}

const beforeBody: Comment[] = [];
const afterBody: RangedComment[] = [];
for (const c of comments) {
const line = c.range?.start.line;
if (line === undefined) {
// No source location — treat as before-body (programmatic comment).
beforeBody.push(c);
continue;
}
if (line < valueNode.startRow) {
beforeBody.push(c);
continue;
}
if (line > valueNode.endRow) {
const trailing = { ...c, attachment: 'trailing' as const };
if (hasRange(trailing)) {
afterBody.push(trailing);
}
continue;
}
// Comments inside the body range are treated as before-body container comments.
beforeBody.push(c);
}
return { beforeBody, afterBody };
}

/** Attach comments to the first entry of a TypedMap-like value. */
export function attachToFirstTypedMapEntry(
value: unknown,
comments: Comment[]
): void {
if (comments.length === 0) return;
if (!isNamedMap(value)) return;

const iterator = value.entries() as IterableIterator<[string, CommentTarget]>;
const first = iterator.next();
if (first.done) return;

attach(first.value[1], comments);
}

/** Attach comments to the first statement in a procedure-like value. */
export function attachToFirstProcedureStatement(
value: unknown,
comments: Comment[]
): void {
if (comments.length === 0) return;
if (!hasProcedureStatements(value)) return;
attach(value.statements[0], comments);
}

/** Attach comments as trailing to the last statement in a procedure-like value. */
export function attachToLastProcedureStatement(
value: unknown,
comments: Comment[]
): boolean {
if (comments.length === 0) return false;
if (!hasProcedureStatements(value)) return false;

const lastStmt = value.statements[value.statements.length - 1];
const tagged = comments.map(c => ({
...c,
attachment: 'trailing' as CommentAttachment,
}));
attach(lastStmt, tagged);
return true;
}
90 changes: 90 additions & 0 deletions packages/language/src/core/cst-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2026, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: Apache-2.0
* For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0
*/

import type { SyntaxNode, Range } from './types.js';
import { toRange } from './types.js';
import type { Diagnostic } from './diagnostics.js';
import { createParserDiagnostic } from './diagnostics.js';

/**
* Compute a diagnostic range for a MISSING CST node.
*
* MISSING nodes are zero-width and sit where the parser gave up, which is
* often the start of the *next* line (after consuming the newline). Anchor
* the range to the previous sibling's end so the squiggly appears on the
* line where the token was actually expected.
*/
export function missingNodeRange(node: SyntaxNode): Range {
const range = toRange(node);
const prev = node.previousSibling;
if (
prev &&
range.start.line === range.end.line &&
range.start.character === range.end.character &&
prev.endPosition.row < node.startPosition.row
) {
const end = prev.endPosition;
return {
start: { line: end.row, character: end.column },
end: { line: end.row, character: end.column },
};
}
return range;
}

/**
* Collect diagnostics for ERROR and MISSING direct children of a CST node.
*
* Called at each AST parse boundary (mapping elements, expressions,
* statements, root) so that diagnostics are attached to the AST node
* that owns that CST region — consistent with how all other diagnostics
* flow through `__diagnostics` → `collectDiagnostics()`.
*
* Checks ALL children (named + anonymous) since MISSING nodes for
* punctuation tokens (`:`, `=`, quotes) are anonymous.
*/
export function collectAllCstDiagnostics(root: SyntaxNode): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
collectCstDiagnosticsInner(root, diagnostics);
return diagnostics;
}

function collectCstDiagnosticsInner(
node: SyntaxNode,
diagnostics: Diagnostic[]
): void {
for (const child of node.children) {
if (child.isMissing) {
diagnostics.push(
createParserDiagnostic(
missingNodeRange(child),
`Missing ${child.type}`,
'missing-token'
)
);
} else if (child.isError) {
// Skip ERROR nodes inside run_statement — RunStatement.parse
// produces a more specific diagnostic for `with ...` errors.
if (node.type !== 'run_statement') {
const text = child.text?.trim();
diagnostics.push(
createParserDiagnostic(
child,
text
? `Syntax error: unexpected \`${text.length > 40 ? text.slice(0, 40) + '…' : text}\``
: 'Syntax error',
'syntax-error'
)
);
}
// Recurse into ERROR children to catch nested MISSING/ERROR nodes
collectCstDiagnosticsInner(child, diagnostics);
} else {
collectCstDiagnosticsInner(child, diagnostics);
}
}
}
Loading
Loading