Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Changed

- VBA classifiers now share one rule dispatcher with consistent scan modes, structural gates, counting, and declarative terminal behavior, preventing future rule tables from silently bypassing their documented preconditions. (#216)
- VBA extraction now exposes only the classifier factories used by the shared source walker, removing obsolete compatibility entry points and other unused public helpers. (#217)

### Fixes
Expand Down
41 changes: 41 additions & 0 deletions __tests__/extraction-vba-rule-dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';

import type { VbaExtractorContext } from '../src/extraction/vba/context';
import {
defineRule,
runRules,
type VbaExtractionRule,
} from '../src/extraction/vba/rules';

const ctx = {} as VbaExtractorContext;

describe('VBA rule dispatcher', () => {
it('honours scan mode for every rule and accumulates emitted counts', () => {
const maskedEmit = vi.fn(() => ({ count: 2 }));
const unmaskedEmit = vi.fn(() => ({ count: 3 }));
const rules: VbaExtractionRule[] = [
defineRule({ id: 'masked', description: 'masked', pattern: /Call/, emit: maskedEmit, count: (r) => (r as { count: number }).count }),
defineRule({ id: 'unmasked', description: 'unmasked', pattern: /secret/, scan: 'unmasked', emit: unmaskedEmit, count: (r) => (r as { count: number }).count }),
];

expect(runRules(rules, ctx, 'Call "secret"', 'Call ""', 1, {})).toBe(5);
expect(maskedEmit).toHaveBeenCalledWith(expect.anything(), ctx, 'Call ""', 1);
expect(unmaskedEmit).toHaveBeenCalledWith(expect.anything(), ctx, 'Call "secret"', 1);
});

it('honours structural gates and declarative terminal rules without inspecting ids', () => {
const skipped = vi.fn(() => ({}));
const terminal = vi.fn(() => ({}));
const afterTerminal = vi.fn(() => ({}));
const rules: VbaExtractionRule[] = [
defineRule({ id: 'any-id', description: 'gated', pattern: /x/, requires: 'inside-procedure', emit: skipped }),
defineRule({ id: 'not-a-special-id', description: 'terminal', pattern: /x/, terminal: true, emit: terminal }),
defineRule({ id: 'later', description: 'later', pattern: /x/, emit: afterTerminal }),
];

expect(runRules(rules, ctx, 'x', 'x', 1, { 'inside-procedure': false })).toBe(1);
expect(skipped).not.toHaveBeenCalled();
expect(terminal).toHaveBeenCalledOnce();
expect(afterTerminal).not.toHaveBeenCalled();
});
});
18 changes: 5 additions & 13 deletions src/extraction/vba/call-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { PROC_RE, PROCEDURE_END_RE, PRIMITIVE_TYPES, isVbaKeyword } from './constants';
import { maskStringContent } from './text-utils';
import { ProcInfo, VbaClassifier } from './context';
import { defineRule, matchRuleForScan, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';
import {
scanRaiseEvents,
scanCallSites,
Expand Down Expand Up @@ -175,6 +175,7 @@ export const RULES: readonly VbaExtractionRule<unknown>[] = [
pattern: WITH_END_RE,
scan: 'masked',
requires: 'inside-procedure',
terminal: true,
emit: (_m, ctx) => {
ctx.vbaWithStack.pop();
return { kind: 'with-end' as const };
Expand Down Expand Up @@ -274,18 +275,9 @@ export function createCallsAndSqlClassifier(
// rules only fire when the call-sweep's per-instance proc stack
// is non-empty (matching the legacy cascade's `if (stack.length
// > 0)` gate).
for (const rule of RULES) {
if (rule.requires === 'inside-procedure' && stack.length === 0) continue;
const matched = matchRuleForScan(rule, line, callScanLine);
if (!matched) continue;
const result = rule.emit(matched.match, ctx, matched.line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
// `with-end` is short-circuited: a line that's `End With` is
// not also a `Set x = New ...`, so break the loop.
if (rule.id === 'with-end') break;
}
this.count += runRules(RULES, ctx, line, callScanLine, lineNum, {
'inside-procedure': stack.length > 0,
});

// Don't scan call sites on the line that declares the procedure — it
// would match the proc name itself in `Sub Outer()`.
Expand Down
29 changes: 6 additions & 23 deletions src/extraction/vba/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Node } from '../../types';
import { generateNodeId } from '../tree-sitter-helpers';
import { foldVisibility } from './text-utils';
import { VbaClassifier } from './context';
import { defineRule, matchRule, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';

/** `[visibility] Event <Name>(...)` custom event declaration. */
const EVENT_DECL_RE =
Expand Down Expand Up @@ -126,6 +126,7 @@ export const RULES: readonly VbaExtractionRule<unknown>[] = [
'Match `End Type` while inside a type block; emit nothing and clear `ctx.vbaDeclTypeBlock` so subsequent lines route back to the outside-type-block rules.',
pattern: TYPE_END_RE,
requires: 'inside-type-block',
terminal: true,
emit: (_m, ctx) => {
ctx.vbaDeclTypeBlock = null;
return { kind: 'end-type-block' as const };
Expand Down Expand Up @@ -231,28 +232,10 @@ export function createEventsTypesDeclaresClassifier(): VbaClassifier {
count: 0,
classifyLine(line, i, ctx) {
const lineNum = i + 1;
for (const rule of RULES) {
// Gate by the rule's `requires` precondition. The
// `outside-type-block` / `inside-type-block` values are the
// concrete preconditions the dispatcher knows about today;
// unknown strings are treated as "always" so adding new
// preconditions does not require touching this loop.
if (rule.requires === 'inside-type-block' && !ctx.vbaDeclTypeBlock) continue;
if (rule.requires === 'outside-type-block' && ctx.vbaDeclTypeBlock) continue;
const m = matchRule(rule.pattern, line);
if (!m) continue;
const result = rule.emit(m, ctx, line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
// `type-end` cleared the type block — nothing else on this
// line can apply (a VBA `End Type` line cannot also be an
// `Event <Name>` declaration). The legacy cascade did the
// same short-circuit via `return`. Break out of the loop
// explicitly so a later refactor cannot accidentally let an
// outside-type-block rule fire on the same line.
if (rule.id === 'type-end') break;
}
this.count += runRules(RULES, ctx, line, line, lineNum, {
'inside-type-block': ctx.vbaDeclTypeBlock !== null,
'outside-type-block': ctx.vbaDeclTypeBlock === null,
});
},
};
return cls;
Expand Down
11 changes: 2 additions & 9 deletions src/extraction/vba/dims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Edge } from '../../types';
import { generateNodeId } from '../tree-sitter-helpers';
import { PRIMITIVE_TYPES, PROC_RE, PROCEDURE_END_RE } from './constants';
import { VbaClassifier } from './context';
import { defineRule, matchRule, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';

/**
* Check that a line is a variable declaration and NOT a Sub/Function/
Expand Down Expand Up @@ -335,14 +335,7 @@ export function createDimsClassifier(): VbaClassifier {
ctx.currentVarTypeProcKey =
stack.length > 0 ? String(stack[stack.length - 1]) : 'module';
}
for (const rule of RULES) {
const m = matchRule(rule.pattern, line);
if (!m) continue;
const result = rule.emit(m, ctx, line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
}
this.count += runRules(RULES, ctx, line, line, lineNum, {});
},
};
}
21 changes: 6 additions & 15 deletions src/extraction/vba/enums-consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { generateNodeId } from '../tree-sitter-helpers';
import { PROC_RE, PROCEDURE_END_RE } from './constants';
import { foldVisibility, parseConstDeclarations } from './text-utils';
import { VbaClassifier } from './context';
import { defineRule, matchRule, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';

/** `[visibility] Enum <Name>` — opens an enum block. */
const ENUM_START_RE =
Expand Down Expand Up @@ -126,6 +126,7 @@ export const RULES: readonly VbaExtractionRule<unknown>[] = [
'Match `End Enum` while inside an enum block; emit nothing and clear `ctx.vbaEnumBlock` so subsequent lines route back to the outside-enum-block rules.',
pattern: ENUM_END_RE,
requires: 'inside-enum-block',
terminal: true,
emit: (_m, ctx) => {
ctx.vbaEnumBlock = null;
return { kind: 'end-enum-block' as const };
Expand Down Expand Up @@ -246,20 +247,10 @@ export function createEnumsConstsClassifier(): VbaClassifier {
count: 0,
classifyLine(line, i, ctx) {
const lineNum = i + 1;
for (const rule of RULES) {
if (rule.requires === 'inside-enum-block' && !ctx.vbaEnumBlock) continue;
if (rule.requires === 'outside-enum-block' && ctx.vbaEnumBlock) continue;
const m = matchRule(rule.pattern, line);
if (!m) continue;
const result = rule.emit(m, ctx, line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
// `enum-end` cleared the enum block — nothing else on this
// line can apply. The legacy cascade did the same short-circuit
// via `return`.
if (rule.id === 'enum-end') break;
}
this.count += runRules(RULES, ctx, line, line, lineNum, {
'inside-enum-block': ctx.vbaEnumBlock !== null,
'outside-enum-block': ctx.vbaEnumBlock === null,
});
},
};
return cls;
Expand Down
11 changes: 2 additions & 9 deletions src/extraction/vba/implements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { Edge } from '../../types';
import { generateNodeId } from '../tree-sitter-helpers';
import { VbaClassifier } from './context';
import { defineRule, matchRule, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';

/** Implements regex. */
const IMPLEMENTS_RE = /^\s*Implements\s+(\p{L}[\p{L}\p{N}_]*)/iu;
Expand Down Expand Up @@ -77,14 +77,7 @@ export function createImplementsClassifier(): VbaClassifier {
count: 0,
classifyLine(line, i, ctx) {
const lineNum = i + 1;
for (const rule of RULES) {
const m = matchRule(rule.pattern, line);
if (!m) continue;
const result = rule.emit(m, ctx, line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
}
this.count += runRules(RULES, ctx, line, line, lineNum, {});
},
};
}
11 changes: 2 additions & 9 deletions src/extraction/vba/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { generateNodeId } from '../tree-sitter-helpers';
import { PROC_RE, PRIMITIVE_TYPES } from './constants';
import { parseEventHandlerName } from './text-utils';
import { ProcInfo, VbaClassifier } from './context';
import { defineRule, matchRule, VbaExtractionRule } from './rules';
import { defineRule, runRules, VbaExtractionRule } from './rules';

/**
* Parse a `Function`/`Property Get` declaration's return type — the `As
Expand Down Expand Up @@ -298,14 +298,7 @@ export function createProceduresClassifier(): VbaClassifier {
count: 0,
classifyLine(line, i, ctx) {
const lineNum = i + 1;
for (const rule of RULES) {
const m = matchRule(rule.pattern, line);
if (!m) continue;
const result = rule.emit(m, ctx, line, lineNum);
if (result !== null && result !== undefined) {
this.count += rule.count ? rule.count(result as never) : 1;
}
}
this.count += runRules(RULES, ctx, line, line, lineNum, {});
},
};
}
43 changes: 38 additions & 5 deletions src/extraction/vba/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,8 @@ import type { VbaExtractorContext } from './context';
* or a non-empty `RegExp[]` (a rule is matched when ANY
* of the alternatives matches). The dispatcher runs
* `.exec()` for single regexes and iterates for arrays.
* - `requires?` Optional structural gate. `'class'` means the rule
* only fires inside a `.cls` file; `'module'` only in
* `.bas`/`.frm`/`.dsr`; `'inside-procedure'` only when
* `ctx.procStack.length > 0`. The orchestrator is
* - `requires?` Optional structural gate from the closed
* `VbaRuleRequirement` union. The orchestrator is
* expected to honour this — keeping the gate declarative
* lets the rule own its own preconditions instead of
* scattering `if` checks into the rule body.
Expand All @@ -76,6 +74,8 @@ import type { VbaExtractorContext } from './context';
* rules whose emit can fan out (e.g. a multi-variable
* `Dim a As Foo, b As Bar` line that produces two
* `references` edges from one match).
* - `terminal?` Stop dispatch after this rule matches, without coupling
* dispatcher control flow to a rule id.
*
* The `count` parameter is typed as `unknown` to keep the
* `VbaExtractionRule<T>` shape covariant in `T` — narrowing `T`
Expand All @@ -88,8 +88,9 @@ export interface VbaExtractionRule<T = unknown> {
readonly id: string;
readonly description: string;
readonly pattern: RegExp | RegExp[];
readonly requires?: 'class' | 'module' | 'inside-procedure' | string;
readonly requires?: VbaRuleRequirement;
readonly scan?: 'masked' | 'unmasked' | 'both';
readonly terminal?: boolean;
readonly emit: (
match: RegExpMatchArray,
ctx: VbaExtractorContext,
Expand All @@ -99,6 +100,15 @@ export interface VbaExtractionRule<T = unknown> {
readonly count?: (result: unknown) => number;
}

export type VbaRuleRequirement =
| 'inside-procedure'
| 'inside-type-block'
| 'outside-type-block'
| 'inside-enum-block'
| 'outside-enum-block';

export type VbaRuleGates = Partial<Record<VbaRuleRequirement, boolean>>;

/**
* Helper to build a `VbaExtractionRule<T>` with a single RegExp.
* Most rules are 1-line declarations; this collapses the boilerplate
Expand Down Expand Up @@ -170,3 +180,26 @@ export function matchRuleForScan(
}
return null;
}

/** Dispatch a rule table with consistent scan, gate, count, and terminal semantics. */
export function runRules(
rules: readonly VbaExtractionRule[],
ctx: VbaExtractorContext,
line: string,
maskedLine: string,
lineNum: number,
gates: VbaRuleGates,
): number {
let count = 0;
for (const rule of rules) {
if (rule.requires && gates[rule.requires] !== true) continue;
const matched = matchRuleForScan(rule, line, maskedLine);
if (!matched) continue;
const result = rule.emit(matched.match, ctx, matched.line, lineNum);
if (result !== null && result !== undefined) {
count += rule.count ? rule.count(result) : 1;
}
if (rule.terminal) break;
}
return count;
}