@sentriflow/core 0.1.0
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.
- package/LICENSE +190 -0
- package/README.md +86 -0
- package/package.json +60 -0
- package/src/constants.ts +77 -0
- package/src/engine/RuleExecutor.ts +256 -0
- package/src/engine/Runner.ts +312 -0
- package/src/engine/SandboxedExecutor.ts +208 -0
- package/src/errors.ts +88 -0
- package/src/helpers/arista/helpers.ts +1220 -0
- package/src/helpers/arista/index.ts +12 -0
- package/src/helpers/aruba/helpers.ts +637 -0
- package/src/helpers/aruba/index.ts +13 -0
- package/src/helpers/cisco/helpers.ts +534 -0
- package/src/helpers/cisco/index.ts +11 -0
- package/src/helpers/common/helpers.ts +265 -0
- package/src/helpers/common/index.ts +5 -0
- package/src/helpers/common/validation.ts +280 -0
- package/src/helpers/cumulus/helpers.ts +676 -0
- package/src/helpers/cumulus/index.ts +12 -0
- package/src/helpers/extreme/helpers.ts +422 -0
- package/src/helpers/extreme/index.ts +12 -0
- package/src/helpers/fortinet/helpers.ts +892 -0
- package/src/helpers/fortinet/index.ts +12 -0
- package/src/helpers/huawei/helpers.ts +790 -0
- package/src/helpers/huawei/index.ts +11 -0
- package/src/helpers/index.ts +53 -0
- package/src/helpers/juniper/helpers.ts +756 -0
- package/src/helpers/juniper/index.ts +12 -0
- package/src/helpers/mikrotik/helpers.ts +722 -0
- package/src/helpers/mikrotik/index.ts +12 -0
- package/src/helpers/nokia/helpers.ts +856 -0
- package/src/helpers/nokia/index.ts +11 -0
- package/src/helpers/paloalto/helpers.ts +939 -0
- package/src/helpers/paloalto/index.ts +12 -0
- package/src/helpers/vyos/helpers.ts +429 -0
- package/src/helpers/vyos/index.ts +12 -0
- package/src/index.ts +30 -0
- package/src/json-rules/ExpressionEvaluator.ts +292 -0
- package/src/json-rules/HelperRegistry.ts +177 -0
- package/src/json-rules/JsonRuleCompiler.ts +339 -0
- package/src/json-rules/JsonRuleValidator.ts +371 -0
- package/src/json-rules/index.ts +97 -0
- package/src/json-rules/schema.json +350 -0
- package/src/json-rules/types.ts +303 -0
- package/src/pack-loader/PackLoader.ts +332 -0
- package/src/pack-loader/index.ts +17 -0
- package/src/pack-loader/types.ts +135 -0
- package/src/parser/IncrementalParser.ts +527 -0
- package/src/parser/Sanitizer.ts +104 -0
- package/src/parser/SchemaAwareParser.ts +504 -0
- package/src/parser/VendorSchema.ts +72 -0
- package/src/parser/vendors/arista-eos.ts +206 -0
- package/src/parser/vendors/aruba-aoscx.ts +123 -0
- package/src/parser/vendors/aruba-aosswitch.ts +113 -0
- package/src/parser/vendors/aruba-wlc.ts +173 -0
- package/src/parser/vendors/cisco-ios.ts +110 -0
- package/src/parser/vendors/cisco-nxos.ts +107 -0
- package/src/parser/vendors/cumulus-linux.ts +161 -0
- package/src/parser/vendors/extreme-exos.ts +154 -0
- package/src/parser/vendors/extreme-voss.ts +167 -0
- package/src/parser/vendors/fortinet-fortigate.ts +217 -0
- package/src/parser/vendors/huawei-vrp.ts +192 -0
- package/src/parser/vendors/index.ts +1521 -0
- package/src/parser/vendors/juniper-junos.ts +230 -0
- package/src/parser/vendors/mikrotik-routeros.ts +274 -0
- package/src/parser/vendors/nokia-sros.ts +251 -0
- package/src/parser/vendors/paloalto-panos.ts +264 -0
- package/src/parser/vendors/vyos-vyos.ts +454 -0
- package/src/types/ConfigNode.ts +72 -0
- package/src/types/DeclarativeRule.ts +158 -0
- package/src/types/IRule.ts +270 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// packages/core/src/pack-loader/types.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SEC-012: Types for the encrypted rule pack system (Consumer API).
|
|
5
|
+
*
|
|
6
|
+
* This module provides types for LOADING encrypted rule packs.
|
|
7
|
+
* For BUILDING packs, use the separate @sentriflow/pack-builder package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IRule, RulePackMetadata } from '../types/IRule';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Error codes for pack loading failures.
|
|
14
|
+
*/
|
|
15
|
+
export const PackLoadErrors = {
|
|
16
|
+
INVALID_FORMAT: 'PACK_INVALID_FORMAT',
|
|
17
|
+
DECRYPTION_FAILED: 'PACK_DECRYPTION_FAILED',
|
|
18
|
+
VALIDATION_FAILED: 'PACK_VALIDATION_FAILED',
|
|
19
|
+
EXPIRED: 'PACK_EXPIRED',
|
|
20
|
+
MACHINE_MISMATCH: 'PACK_MACHINE_MISMATCH',
|
|
21
|
+
ACTIVATION_LIMIT: 'PACK_ACTIVATION_LIMIT',
|
|
22
|
+
EXECUTION_ERROR: 'PACK_EXECUTION_ERROR',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type PackLoadErrorCode = keyof typeof PackLoadErrors;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Error thrown when loading an encrypted pack fails.
|
|
29
|
+
*/
|
|
30
|
+
export class PackLoadError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
public readonly code: PackLoadErrorCode,
|
|
33
|
+
message: string,
|
|
34
|
+
public readonly details?: Record<string, unknown>
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'PackLoadError';
|
|
38
|
+
if (Error.captureStackTrace) {
|
|
39
|
+
Error.captureStackTrace(this, this.constructor);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options for loading an encrypted pack.
|
|
46
|
+
*/
|
|
47
|
+
export interface PackLoadOptions {
|
|
48
|
+
/** The license key for decryption */
|
|
49
|
+
licenseKey: string;
|
|
50
|
+
/** Optional machine ID for node-locked licenses */
|
|
51
|
+
machineId?: string;
|
|
52
|
+
/** Optional callback to get current activation count */
|
|
53
|
+
getActivationCount?: (packName: string) => number;
|
|
54
|
+
/** Execution timeout in milliseconds (default: 5000) */
|
|
55
|
+
timeout?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* License information embedded in the pack.
|
|
60
|
+
* Displayed to users to show their subscription details.
|
|
61
|
+
*/
|
|
62
|
+
export interface LicenseInfo {
|
|
63
|
+
/** Customer name */
|
|
64
|
+
customerName: string;
|
|
65
|
+
/** Customer email */
|
|
66
|
+
customerEmail: string;
|
|
67
|
+
/** Company name (if provided) */
|
|
68
|
+
company?: string;
|
|
69
|
+
/** Contract/subscription ID (if provided) */
|
|
70
|
+
contractId?: string;
|
|
71
|
+
/** ISO date string when the license was activated */
|
|
72
|
+
activatedAt: string;
|
|
73
|
+
/** ISO date string when the license expires */
|
|
74
|
+
expiresAt: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Result of successfully loading an encrypted pack.
|
|
79
|
+
*/
|
|
80
|
+
export interface LoadedPack {
|
|
81
|
+
/** Pack metadata */
|
|
82
|
+
metadata: RulePackMetadata;
|
|
83
|
+
/** Validated and ready-to-use rules */
|
|
84
|
+
rules: IRule[];
|
|
85
|
+
/** ISO date string of when the pack expires */
|
|
86
|
+
validUntil: string;
|
|
87
|
+
/** License information for display purposes */
|
|
88
|
+
licenseInfo?: LicenseInfo;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Encrypted rule pack file format (.grpx).
|
|
93
|
+
*
|
|
94
|
+
* Binary structure:
|
|
95
|
+
* - magic: 4 bytes ("GRPX")
|
|
96
|
+
* - version: 1 byte (format version)
|
|
97
|
+
* - algorithm: 1 byte (1=AES-256-GCM)
|
|
98
|
+
* - kdf: 1 byte (1=PBKDF2, 2=Argon2id)
|
|
99
|
+
* - reserved: 5 bytes
|
|
100
|
+
* - iv: 12 bytes
|
|
101
|
+
* - tag: 16 bytes (GCM auth tag)
|
|
102
|
+
* - salt: 32 bytes (KDF salt)
|
|
103
|
+
* - payloadLength: 4 bytes (uint32 BE)
|
|
104
|
+
* - encryptedPayload: variable length
|
|
105
|
+
*
|
|
106
|
+
* Total header: 76 bytes + payload
|
|
107
|
+
*/
|
|
108
|
+
export interface EncryptedPackHeader {
|
|
109
|
+
magic: 'GRPX';
|
|
110
|
+
version: number;
|
|
111
|
+
algorithm: number;
|
|
112
|
+
kdf: number;
|
|
113
|
+
iv: Buffer;
|
|
114
|
+
tag: Buffer;
|
|
115
|
+
salt: Buffer;
|
|
116
|
+
payloadLength: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Constants for the encrypted pack format.
|
|
121
|
+
* Shared between builder (@sentriflow/pack-builder) and loader (@sentriflow/core).
|
|
122
|
+
*/
|
|
123
|
+
export const GRPX_CONSTANTS = {
|
|
124
|
+
MAGIC: 'GRPX',
|
|
125
|
+
HEADER_SIZE: 76,
|
|
126
|
+
CURRENT_VERSION: 1,
|
|
127
|
+
ALG_AES_256_GCM: 1,
|
|
128
|
+
KDF_PBKDF2: 1,
|
|
129
|
+
KDF_ARGON2ID: 2,
|
|
130
|
+
PBKDF2_ITERATIONS: 100000,
|
|
131
|
+
KEY_LENGTH: 32,
|
|
132
|
+
IV_LENGTH: 12,
|
|
133
|
+
TAG_LENGTH: 16,
|
|
134
|
+
SALT_LENGTH: 32,
|
|
135
|
+
} as const;
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// packages/core/src/parser/IncrementalParser.ts
|
|
2
|
+
|
|
3
|
+
import type { ConfigNode } from '../types/ConfigNode';
|
|
4
|
+
import { SchemaAwareParser } from './SchemaAwareParser';
|
|
5
|
+
import type { ParserOptions } from './SchemaAwareParser';
|
|
6
|
+
import type { VendorSchema } from './VendorSchema';
|
|
7
|
+
import { defaultVendor, detectVendor } from './vendors';
|
|
8
|
+
import { INCREMENTAL_PARSE_THRESHOLD } from '../constants';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for the IncrementalParser.
|
|
12
|
+
*/
|
|
13
|
+
export interface IncrementalParserOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Vendor schema to use for parsing.
|
|
16
|
+
* - If not specified, defaults to auto-detection.
|
|
17
|
+
* - Use 'auto' to auto-detect vendor from config content.
|
|
18
|
+
* - Use a specific VendorSchema for explicit vendor selection.
|
|
19
|
+
*/
|
|
20
|
+
vendor?: VendorSchema | 'auto';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cached document state for incremental parsing.
|
|
25
|
+
*/
|
|
26
|
+
interface DocumentCache {
|
|
27
|
+
/** The parsed AST */
|
|
28
|
+
ast: ConfigNode[];
|
|
29
|
+
/** Hash of each line for change detection */
|
|
30
|
+
lineHashes: string[];
|
|
31
|
+
/** Document version (increments on each edit) */
|
|
32
|
+
version: number;
|
|
33
|
+
/** Original line count */
|
|
34
|
+
lineCount: number;
|
|
35
|
+
/** Vendor used for parsing this document */
|
|
36
|
+
vendor: VendorSchema;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Represents a range of changed lines.
|
|
41
|
+
*/
|
|
42
|
+
interface ChangeRange {
|
|
43
|
+
startLine: number;
|
|
44
|
+
endLine: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Statistics about the last parse operation.
|
|
49
|
+
*/
|
|
50
|
+
export interface ParseStats {
|
|
51
|
+
/** Whether a full parse was performed */
|
|
52
|
+
fullParse: boolean;
|
|
53
|
+
/** Number of changed line ranges detected */
|
|
54
|
+
changedRanges: number;
|
|
55
|
+
/** Number of sections re-parsed (if incremental) */
|
|
56
|
+
sectionsReparsed: number;
|
|
57
|
+
/** Parse time in milliseconds */
|
|
58
|
+
parseTimeMs: number;
|
|
59
|
+
/** Reason for full parse (if applicable) */
|
|
60
|
+
fullParseReason?: string;
|
|
61
|
+
/** Vendor used for parsing */
|
|
62
|
+
vendorId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Incremental parser that caches ASTs and only re-parses changed sections.
|
|
67
|
+
* Supports multiple vendors through the VendorSchema system.
|
|
68
|
+
*
|
|
69
|
+
* Performance characteristics:
|
|
70
|
+
* - Single line edit: Only affected section re-parsed (~5-20x faster)
|
|
71
|
+
* - Multi-line paste: Affected sections re-parsed (~2-10x faster)
|
|
72
|
+
* - Large changes (>30%): Falls back to full re-parse
|
|
73
|
+
*
|
|
74
|
+
* Vendor support:
|
|
75
|
+
* - Auto-detection: Analyzes config content to determine vendor
|
|
76
|
+
* - Explicit vendor: Pass VendorSchema in options
|
|
77
|
+
* - Cached vendor: Re-uses detected vendor for subsequent parses
|
|
78
|
+
*
|
|
79
|
+
* Usage:
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const parser = new IncrementalParser();
|
|
82
|
+
*
|
|
83
|
+
* // First parse - auto-detects vendor, full parse, cached
|
|
84
|
+
* const ast1 = parser.parse('doc1', content1, 1);
|
|
85
|
+
*
|
|
86
|
+
* // Edit - incremental parse if possible, same vendor
|
|
87
|
+
* const ast2 = parser.parse('doc1', content2, 2);
|
|
88
|
+
*
|
|
89
|
+
* // Explicit vendor
|
|
90
|
+
* const parser2 = new IncrementalParser({ vendor: JuniperJunOSSchema });
|
|
91
|
+
* const ast3 = parser2.parse('junos-config', junosContent, 1);
|
|
92
|
+
*
|
|
93
|
+
* // Clear cache when document closes
|
|
94
|
+
* parser.invalidate('doc1');
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export class IncrementalParser {
|
|
98
|
+
private cache = new Map<string, DocumentCache>();
|
|
99
|
+
private lastStats: ParseStats | null = null;
|
|
100
|
+
private readonly defaultVendorOption: VendorSchema | 'auto';
|
|
101
|
+
|
|
102
|
+
constructor(options?: IncrementalParserOptions) {
|
|
103
|
+
this.defaultVendorOption = options?.vendor ?? 'auto';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse document content, using cached AST when possible.
|
|
108
|
+
*
|
|
109
|
+
* @param uri Unique identifier for the document (e.g., file URI)
|
|
110
|
+
* @param content The document content to parse
|
|
111
|
+
* @param version Document version number (should increment on each edit)
|
|
112
|
+
* @param vendor Optional vendor override for this specific parse
|
|
113
|
+
* @returns Parsed AST
|
|
114
|
+
*/
|
|
115
|
+
public parse(
|
|
116
|
+
uri: string,
|
|
117
|
+
content: string,
|
|
118
|
+
version: number,
|
|
119
|
+
vendor?: VendorSchema | 'auto'
|
|
120
|
+
): ConfigNode[] {
|
|
121
|
+
const startTime = performance.now();
|
|
122
|
+
const cached = this.cache.get(uri);
|
|
123
|
+
const lines = content.split('\n');
|
|
124
|
+
const lineHashes = lines.map((line) => this.hashLine(line));
|
|
125
|
+
|
|
126
|
+
// Determine vendor to use
|
|
127
|
+
const vendorOption = vendor ?? this.defaultVendorOption;
|
|
128
|
+
const resolvedVendor = this.resolveVendor(vendorOption, content, cached);
|
|
129
|
+
|
|
130
|
+
// Full parse if no cache
|
|
131
|
+
if (!cached) {
|
|
132
|
+
const ast = this.fullParse(content, resolvedVendor);
|
|
133
|
+
this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
|
|
134
|
+
this.lastStats = {
|
|
135
|
+
fullParse: true,
|
|
136
|
+
changedRanges: 0,
|
|
137
|
+
sectionsReparsed: 0,
|
|
138
|
+
parseTimeMs: performance.now() - startTime,
|
|
139
|
+
fullParseReason: 'no_cache',
|
|
140
|
+
vendorId: resolvedVendor.id,
|
|
141
|
+
};
|
|
142
|
+
return ast;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Full parse if vendor changed
|
|
146
|
+
if (cached.vendor.id !== resolvedVendor.id) {
|
|
147
|
+
const ast = this.fullParse(content, resolvedVendor);
|
|
148
|
+
this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
|
|
149
|
+
this.lastStats = {
|
|
150
|
+
fullParse: true,
|
|
151
|
+
changedRanges: 0,
|
|
152
|
+
sectionsReparsed: 0,
|
|
153
|
+
parseTimeMs: performance.now() - startTime,
|
|
154
|
+
fullParseReason: 'vendor_changed',
|
|
155
|
+
vendorId: resolvedVendor.id,
|
|
156
|
+
};
|
|
157
|
+
return ast;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Full parse if version hasn't increased (stale request)
|
|
161
|
+
if (version <= cached.version) {
|
|
162
|
+
this.lastStats = {
|
|
163
|
+
fullParse: false,
|
|
164
|
+
changedRanges: 0,
|
|
165
|
+
sectionsReparsed: 0,
|
|
166
|
+
parseTimeMs: performance.now() - startTime,
|
|
167
|
+
vendorId: resolvedVendor.id,
|
|
168
|
+
};
|
|
169
|
+
return cached.ast;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Find changed line ranges
|
|
173
|
+
const changedRanges = this.findChangedRanges(cached.lineHashes, lineHashes);
|
|
174
|
+
|
|
175
|
+
// Calculate percentage of lines changed
|
|
176
|
+
const totalChangedLines = changedRanges.reduce(
|
|
177
|
+
(sum, range) => sum + (range.endLine - range.startLine + 1),
|
|
178
|
+
0
|
|
179
|
+
);
|
|
180
|
+
const changeRatio = totalChangedLines / Math.max(lines.length, cached.lineCount);
|
|
181
|
+
|
|
182
|
+
// Full parse if too many changes or structural changes detected
|
|
183
|
+
if (changeRatio > INCREMENTAL_PARSE_THRESHOLD || this.hasStructuralChanges(changedRanges, cached, lines)) {
|
|
184
|
+
const ast = this.fullParse(content, resolvedVendor);
|
|
185
|
+
this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
|
|
186
|
+
this.lastStats = {
|
|
187
|
+
fullParse: true,
|
|
188
|
+
changedRanges: changedRanges.length,
|
|
189
|
+
sectionsReparsed: 0,
|
|
190
|
+
parseTimeMs: performance.now() - startTime,
|
|
191
|
+
fullParseReason: changeRatio > INCREMENTAL_PARSE_THRESHOLD ? 'too_many_changes' : 'structural_changes',
|
|
192
|
+
vendorId: resolvedVendor.id,
|
|
193
|
+
};
|
|
194
|
+
return ast;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// No changes detected
|
|
198
|
+
if (changedRanges.length === 0) {
|
|
199
|
+
cached.version = version;
|
|
200
|
+
this.lastStats = {
|
|
201
|
+
fullParse: false,
|
|
202
|
+
changedRanges: 0,
|
|
203
|
+
sectionsReparsed: 0,
|
|
204
|
+
parseTimeMs: performance.now() - startTime,
|
|
205
|
+
vendorId: resolvedVendor.id,
|
|
206
|
+
};
|
|
207
|
+
return cached.ast;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Incremental update
|
|
211
|
+
const { ast: updatedAst, sectionsReparsed } = this.incrementalUpdate(
|
|
212
|
+
cached.ast,
|
|
213
|
+
lines,
|
|
214
|
+
changedRanges,
|
|
215
|
+
resolvedVendor
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
this.cache.set(uri, { ast: updatedAst, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
|
|
219
|
+
this.lastStats = {
|
|
220
|
+
fullParse: false,
|
|
221
|
+
changedRanges: changedRanges.length,
|
|
222
|
+
sectionsReparsed,
|
|
223
|
+
parseTimeMs: performance.now() - startTime,
|
|
224
|
+
vendorId: resolvedVendor.id,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return updatedAst;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the vendor to use for parsing.
|
|
232
|
+
* @param vendorOption The vendor option (VendorSchema or 'auto')
|
|
233
|
+
* @param content The config content for auto-detection
|
|
234
|
+
* @param cached Optional cached document for reusing vendor
|
|
235
|
+
*/
|
|
236
|
+
private resolveVendor(
|
|
237
|
+
vendorOption: VendorSchema | 'auto',
|
|
238
|
+
content: string,
|
|
239
|
+
cached?: DocumentCache
|
|
240
|
+
): VendorSchema {
|
|
241
|
+
if (vendorOption !== 'auto') {
|
|
242
|
+
return vendorOption;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// If cached and auto, reuse the detected vendor for consistency
|
|
246
|
+
if (cached) {
|
|
247
|
+
return cached.vendor;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Auto-detect from content
|
|
251
|
+
return detectVendor(content);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Perform a full parse using SchemaAwareParser.
|
|
256
|
+
*/
|
|
257
|
+
private fullParse(content: string, vendor: VendorSchema): ConfigNode[] {
|
|
258
|
+
const parser = new SchemaAwareParser({ vendor });
|
|
259
|
+
return parser.parse(content);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Simple string hash for fast line comparison.
|
|
264
|
+
* Uses djb2 algorithm for good distribution.
|
|
265
|
+
*/
|
|
266
|
+
private hashLine(line: string): string {
|
|
267
|
+
let hash = 5381;
|
|
268
|
+
for (let i = 0; i < line.length; i++) {
|
|
269
|
+
hash = ((hash << 5) + hash) ^ line.charCodeAt(i);
|
|
270
|
+
}
|
|
271
|
+
return (hash >>> 0).toString(36);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Find ranges of consecutive changed lines.
|
|
276
|
+
*/
|
|
277
|
+
private findChangedRanges(oldHashes: string[], newHashes: string[]): ChangeRange[] {
|
|
278
|
+
const ranges: ChangeRange[] = [];
|
|
279
|
+
let inChange = false;
|
|
280
|
+
let changeStart = 0;
|
|
281
|
+
|
|
282
|
+
const maxLen = Math.max(oldHashes.length, newHashes.length);
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < maxLen; i++) {
|
|
285
|
+
const changed = oldHashes[i] !== newHashes[i];
|
|
286
|
+
|
|
287
|
+
if (changed && !inChange) {
|
|
288
|
+
inChange = true;
|
|
289
|
+
changeStart = i;
|
|
290
|
+
} else if (!changed && inChange) {
|
|
291
|
+
inChange = false;
|
|
292
|
+
ranges.push({
|
|
293
|
+
startLine: changeStart,
|
|
294
|
+
endLine: i - 1,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle change that extends to end of file
|
|
300
|
+
if (inChange) {
|
|
301
|
+
ranges.push({
|
|
302
|
+
startLine: changeStart,
|
|
303
|
+
endLine: maxLen - 1,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return ranges;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Detect structural changes that require full re-parse.
|
|
312
|
+
* Examples: Line count changed significantly, changes span multiple sections.
|
|
313
|
+
*/
|
|
314
|
+
private hasStructuralChanges(
|
|
315
|
+
changedRanges: ChangeRange[],
|
|
316
|
+
cached: DocumentCache,
|
|
317
|
+
newLines: string[]
|
|
318
|
+
): boolean {
|
|
319
|
+
// Significant line count change (insertions/deletions)
|
|
320
|
+
const lineDelta = Math.abs(newLines.length - cached.lineCount);
|
|
321
|
+
if (lineDelta > 10) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Changes affect multiple top-level sections
|
|
326
|
+
let affectedSections = 0;
|
|
327
|
+
for (const range of changedRanges) {
|
|
328
|
+
for (const node of cached.ast) {
|
|
329
|
+
if (this.rangeOverlapsNode(range, node)) {
|
|
330
|
+
affectedSections++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// If changes affect more than half of top-level sections, do full parse
|
|
336
|
+
if (affectedSections > cached.ast.length / 2) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Check if a change range overlaps with a node's location.
|
|
345
|
+
*/
|
|
346
|
+
private rangeOverlapsNode(range: ChangeRange, node: ConfigNode): boolean {
|
|
347
|
+
return range.startLine <= node.loc.endLine && range.endLine >= node.loc.startLine;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Incrementally update AST by re-parsing only affected sections.
|
|
352
|
+
*/
|
|
353
|
+
private incrementalUpdate(
|
|
354
|
+
oldAst: ConfigNode[],
|
|
355
|
+
lines: string[],
|
|
356
|
+
changedRanges: ChangeRange[],
|
|
357
|
+
vendor: VendorSchema
|
|
358
|
+
): { ast: ConfigNode[]; sectionsReparsed: number } {
|
|
359
|
+
// Find which top-level sections are affected by changes
|
|
360
|
+
const affectedSectionIndices = new Set<number>();
|
|
361
|
+
|
|
362
|
+
for (const range of changedRanges) {
|
|
363
|
+
for (let i = 0; i < oldAst.length; i++) {
|
|
364
|
+
const node = oldAst[i];
|
|
365
|
+
if (node && this.rangeOverlapsNode(range, node)) {
|
|
366
|
+
affectedSectionIndices.add(i);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// If no sections affected but we have changes, changes are in gaps between sections
|
|
372
|
+
// or at the end of file - do full re-parse to be safe
|
|
373
|
+
if (affectedSectionIndices.size === 0 && changedRanges.length > 0) {
|
|
374
|
+
const parser = new SchemaAwareParser({ vendor });
|
|
375
|
+
return { ast: parser.parse(lines.join('\n')), sectionsReparsed: oldAst.length };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Calculate line offset caused by insertions/deletions
|
|
379
|
+
// For simplicity, we'll re-parse affected sections with adjusted line numbers
|
|
380
|
+
const newAst: ConfigNode[] = [];
|
|
381
|
+
let lineOffset = 0;
|
|
382
|
+
|
|
383
|
+
for (let i = 0; i < oldAst.length; i++) {
|
|
384
|
+
const node = oldAst[i];
|
|
385
|
+
if (!node) continue;
|
|
386
|
+
|
|
387
|
+
if (affectedSectionIndices.has(i)) {
|
|
388
|
+
// Find the section boundaries in the new content
|
|
389
|
+
const sectionStart = node.loc.startLine + lineOffset;
|
|
390
|
+
const nextNode = i < oldAst.length - 1 ? oldAst[i + 1] : null;
|
|
391
|
+
const sectionEnd = this.findSectionEnd(lines, sectionStart, nextNode ?? null, lineOffset);
|
|
392
|
+
|
|
393
|
+
if (sectionStart < lines.length) {
|
|
394
|
+
// Extract and re-parse this section
|
|
395
|
+
const sectionLines = lines.slice(sectionStart, sectionEnd + 1);
|
|
396
|
+
const sectionContent = sectionLines.join('\n');
|
|
397
|
+
|
|
398
|
+
const sectionParser = new SchemaAwareParser({
|
|
399
|
+
startLine: sectionStart,
|
|
400
|
+
vendor,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const reparsedNodes = sectionParser.parse(sectionContent);
|
|
404
|
+
|
|
405
|
+
// Add re-parsed nodes
|
|
406
|
+
newAst.push(...reparsedNodes);
|
|
407
|
+
|
|
408
|
+
// Update line offset based on size change
|
|
409
|
+
const oldSectionSize = node.loc.endLine - node.loc.startLine + 1;
|
|
410
|
+
const newSectionSize = sectionEnd - sectionStart + 1;
|
|
411
|
+
lineOffset += newSectionSize - oldSectionSize;
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// Keep existing node but adjust line numbers
|
|
415
|
+
const adjustedNode = this.adjustNodeLineNumbers(node, lineOffset);
|
|
416
|
+
newAst.push(adjustedNode);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return { ast: newAst, sectionsReparsed: affectedSectionIndices.size };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Find the end line of a section in the new content.
|
|
425
|
+
*/
|
|
426
|
+
private findSectionEnd(
|
|
427
|
+
lines: string[],
|
|
428
|
+
sectionStart: number,
|
|
429
|
+
nextSection: ConfigNode | null,
|
|
430
|
+
lineOffset: number
|
|
431
|
+
): number {
|
|
432
|
+
// If there's a next section, the current section ends just before it
|
|
433
|
+
if (nextSection) {
|
|
434
|
+
const nextStart = nextSection.loc.startLine + lineOffset;
|
|
435
|
+
// Find the last non-empty line before the next section
|
|
436
|
+
let end = nextStart - 1;
|
|
437
|
+
while (end > sectionStart && lines[end]?.trim() === '') {
|
|
438
|
+
end--;
|
|
439
|
+
}
|
|
440
|
+
return Math.max(sectionStart, end);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Last section - extends to end of file
|
|
444
|
+
let end = lines.length - 1;
|
|
445
|
+
while (end > sectionStart && lines[end]?.trim() === '') {
|
|
446
|
+
end--;
|
|
447
|
+
}
|
|
448
|
+
return Math.max(sectionStart, end);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Recursively adjust line numbers in a node and its children.
|
|
453
|
+
*/
|
|
454
|
+
private adjustNodeLineNumbers(node: ConfigNode, offset: number): ConfigNode {
|
|
455
|
+
if (offset === 0) {
|
|
456
|
+
return node;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
...node,
|
|
461
|
+
loc: {
|
|
462
|
+
startLine: node.loc.startLine + offset,
|
|
463
|
+
endLine: node.loc.endLine + offset,
|
|
464
|
+
},
|
|
465
|
+
children: node.children.map((child) => this.adjustNodeLineNumbers(child, offset)),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Get statistics about the last parse operation.
|
|
471
|
+
*/
|
|
472
|
+
public getLastStats(): ParseStats | null {
|
|
473
|
+
return this.lastStats;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get the vendor used for a cached document.
|
|
478
|
+
*
|
|
479
|
+
* @param uri The document URI
|
|
480
|
+
* @returns The VendorSchema or undefined if not cached
|
|
481
|
+
*/
|
|
482
|
+
public getCachedVendor(uri: string): VendorSchema | undefined {
|
|
483
|
+
return this.cache.get(uri)?.vendor;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Clear cache for a specific document.
|
|
488
|
+
*
|
|
489
|
+
* @param uri The document URI to invalidate
|
|
490
|
+
*/
|
|
491
|
+
public invalidate(uri: string): void {
|
|
492
|
+
this.cache.delete(uri);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Clear all cached documents.
|
|
497
|
+
*/
|
|
498
|
+
public clearAll(): void {
|
|
499
|
+
this.cache.clear();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Get the number of cached documents.
|
|
504
|
+
*/
|
|
505
|
+
public getCacheSize(): number {
|
|
506
|
+
return this.cache.size;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Check if a document is cached.
|
|
511
|
+
*
|
|
512
|
+
* @param uri The document URI to check
|
|
513
|
+
*/
|
|
514
|
+
public isCached(uri: string): boolean {
|
|
515
|
+
return this.cache.has(uri);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Get cached document version.
|
|
520
|
+
*
|
|
521
|
+
* @param uri The document URI
|
|
522
|
+
* @returns The cached version or -1 if not cached
|
|
523
|
+
*/
|
|
524
|
+
public getCachedVersion(uri: string): number {
|
|
525
|
+
return this.cache.get(uri)?.version ?? -1;
|
|
526
|
+
}
|
|
527
|
+
}
|