Skip to content

Commit 1b149a6

Browse files
committed
feat: implement scope tree construction system
1 parent 418c02d commit 1b149a6

File tree

4 files changed

+573
-133
lines changed

4 files changed

+573
-133
lines changed

src/context/index.ts

Lines changed: 237 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { Effect } from 'effect'
22
import type { RebuiltText } from '../chunking/rebuild'
3+
import { findScopeAtOffset, getAncestorChain } from '../scope/tree'
34
import type {
45
ByteRange,
56
Chunk,
67
ChunkContext,
78
ChunkOptions,
9+
EntityInfo,
10+
ExtractedEntity,
11+
ImportInfo,
812
ScopeTree,
913
} from '../types'
14+
import { getSiblings, type SiblingOptions } from './siblings'
1015

1116
/**
1217
* Error when attaching context fails
@@ -19,88 +24,136 @@ export class ContextError {
1924
) {}
2025
}
2126

22-
/**
23-
* Attach context information to a chunk
24-
*
25-
* @param text - The rebuilt text info for the chunk
26-
* @param scopeTree - The scope tree for the file
27-
* @param options - Chunking options
28-
* @param index - The chunk index
29-
* @param totalChunks - Total number of chunks
30-
* @returns Effect yielding the complete chunk with context
31-
*
32-
* TODO: Implement context attachment
33-
*/
34-
export const attachContext = (
35-
text: RebuiltText,
36-
scopeTree: ScopeTree,
37-
options: ChunkOptions,
38-
index: number,
39-
totalChunks: number,
40-
): Effect.Effect<Chunk, ContextError> => {
41-
// TODO: Implement context attachment
42-
// 1. Find scope for this chunk's byte range
43-
// 2. Get entities within the chunk
44-
// 3. Get siblings based on options
45-
// 4. Get relevant imports
46-
const context: ChunkContext = {
47-
scope: [],
48-
entities: [],
49-
siblings: [],
50-
imports: [],
51-
}
52-
53-
void scopeTree
54-
void options
55-
56-
return Effect.succeed({
57-
text: text.text,
58-
byteRange: text.byteRange,
59-
lineRange: text.lineRange,
60-
context,
61-
index,
62-
totalChunks,
63-
})
64-
}
65-
6627
/**
6728
* Get scope information for a byte range
6829
*
30+
* Finds the scope containing this range and builds an array of EntityInfo
31+
* from the scope and its ancestors.
32+
*
6933
* @param byteRange - The byte range to get scope for
7034
* @param scopeTree - The scope tree
71-
* @returns Scope entity info array
72-
*
73-
* TODO: Implement scope lookup
35+
* @returns Scope entity info array representing the scope chain
7436
*/
7537
export const getScopeForRange = (
7638
byteRange: ByteRange,
7739
scopeTree: ScopeTree,
7840
): ChunkContext['scope'] => {
79-
// TODO: Implement scope lookup
80-
// Find containing scopes and return as EntityInfo[]
81-
void byteRange
82-
void scopeTree
83-
return []
41+
// Find the scope at the start of the range
42+
const scope = findScopeAtOffset(scopeTree, byteRange.start)
43+
44+
if (!scope) {
45+
return []
46+
}
47+
48+
// Build scope chain: current scope + ancestors
49+
const scopeChain: EntityInfo[] = []
50+
51+
// Add current scope
52+
scopeChain.push({
53+
name: scope.entity.name,
54+
type: scope.entity.type,
55+
signature: scope.entity.signature,
56+
})
57+
58+
// Add ancestors (from immediate parent to root)
59+
const ancestors = getAncestorChain(scope)
60+
for (const ancestor of ancestors) {
61+
scopeChain.push({
62+
name: ancestor.entity.name,
63+
type: ancestor.entity.type,
64+
signature: ancestor.entity.signature,
65+
})
66+
}
67+
68+
return scopeChain
8469
}
8570

8671
/**
8772
* Get entities within a byte range
8873
*
74+
* Finds entities whose byte ranges overlap with the given range.
75+
* Overlap condition: entity.start < range.end && entity.end > range.start
76+
*
8977
* @param byteRange - The byte range to search
9078
* @param scopeTree - The scope tree
9179
* @returns Entity info array for entities in range
92-
*
93-
* TODO: Implement entity lookup
9480
*/
9581
export const getEntitiesInRange = (
9682
byteRange: ByteRange,
9783
scopeTree: ScopeTree,
9884
): ChunkContext['entities'] => {
99-
// TODO: Implement entity lookup
100-
// Find entities whose ranges overlap with byteRange
101-
void byteRange
102-
void scopeTree
103-
return []
85+
const overlappingEntities = scopeTree.allEntities.filter((entity) => {
86+
// Overlap check: entity.start < range.end && entity.end > range.start
87+
return (
88+
entity.byteRange.start < byteRange.end &&
89+
entity.byteRange.end > byteRange.start
90+
)
91+
})
92+
93+
// Map to EntityInfo
94+
return overlappingEntities.map((entity) => ({
95+
name: entity.name,
96+
type: entity.type,
97+
signature: entity.signature,
98+
}))
99+
}
100+
101+
/**
102+
* Parse import source from an import entity
103+
*
104+
* Extracts the source module path from import signatures like:
105+
* - `import { foo } from 'module'`
106+
* - `import foo from 'module'`
107+
* - `import * as foo from 'module'`
108+
*
109+
* @param entity - The import entity
110+
* @returns The import source or empty string if not found
111+
*/
112+
const parseImportSource = (entity: ExtractedEntity): string => {
113+
// Try to extract from signature using regex
114+
// Common patterns: from 'source' or from "source"
115+
const fromMatch = entity.signature.match(/from\s+['"]([^'"]+)['"]/)
116+
if (fromMatch?.[1]) {
117+
return fromMatch[1]
118+
}
119+
120+
// For CommonJS style: require('source')
121+
const requireMatch = entity.signature.match(/require\s*\(\s*['"]([^'"]+)['"]/)
122+
if (requireMatch?.[1]) {
123+
return requireMatch[1]
124+
}
125+
126+
return ''
127+
}
128+
129+
/**
130+
* Check if an import is a default import
131+
*
132+
* @param entity - The import entity
133+
* @returns Whether this is a default import
134+
*/
135+
const isDefaultImport = (entity: ExtractedEntity): boolean => {
136+
// Default import patterns:
137+
// import foo from 'module'
138+
// But NOT: import { foo } from 'module'
139+
// And NOT: import * as foo from 'module'
140+
const signature = entity.signature
141+
return (
142+
/^import\s+\w+\s+from/.test(signature) &&
143+
!/^import\s*\{/.test(signature) &&
144+
!/^import\s*\*/.test(signature)
145+
)
146+
}
147+
148+
/**
149+
* Check if an import is a namespace import
150+
*
151+
* @param entity - The import entity
152+
* @returns Whether this is a namespace import
153+
*/
154+
const isNamespaceImport = (entity: ExtractedEntity): boolean => {
155+
// Namespace import pattern: import * as foo from 'module'
156+
return /^import\s*\*\s*as\s+\w+/.test(entity.signature)
104157
}
105158

106159
/**
@@ -110,18 +163,135 @@ export const getEntitiesInRange = (
110163
* @param scopeTree - The scope tree
111164
* @param filterImports - Whether to filter to only used imports
112165
* @returns Import info array
113-
*
114-
* TODO: Implement import filtering
115166
*/
116167
export const getRelevantImports = (
117168
entities: ChunkContext['entities'],
118169
scopeTree: ScopeTree,
119170
filterImports: boolean,
120171
): ChunkContext['imports'] => {
121-
// TODO: Implement import filtering
122-
// If filterImports, only include imports used by chunk entities
123-
void entities
124-
void scopeTree
125-
void filterImports
126-
return []
172+
const imports = scopeTree.imports
173+
174+
if (imports.length === 0) {
175+
return []
176+
}
177+
178+
// Map import entity to ImportInfo
179+
const mapToImportInfo = (entity: ExtractedEntity): ImportInfo => ({
180+
name: entity.name,
181+
source: parseImportSource(entity),
182+
isDefault: isDefaultImport(entity) || undefined,
183+
isNamespace: isNamespaceImport(entity) || undefined,
184+
})
185+
186+
// If not filtering, return all imports
187+
if (!filterImports) {
188+
return imports.map(mapToImportInfo)
189+
}
190+
191+
// Filter to only imports that are used by entities in the chunk
192+
// Build a set of names that appear in entity signatures and names
193+
const usedNames = new Set<string>()
194+
for (const entity of entities) {
195+
// Add the entity name
196+
usedNames.add(entity.name)
197+
198+
// Extract identifiers from signature if available
199+
if (entity.signature) {
200+
// Match word characters that could be identifiers
201+
const identifiers = entity.signature.match(
202+
/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g,
203+
)
204+
if (identifiers) {
205+
for (const id of identifiers) {
206+
usedNames.add(id)
207+
}
208+
}
209+
}
210+
}
211+
212+
// Filter imports to those whose names appear in the chunk
213+
const filteredImports = imports.filter((importEntity) => {
214+
return usedNames.has(importEntity.name)
215+
})
216+
217+
return filteredImports.map(mapToImportInfo)
218+
}
219+
220+
/**
221+
* Attach context information to a chunk
222+
*
223+
* @param text - The rebuilt text info for the chunk
224+
* @param scopeTree - The scope tree for the file
225+
* @param options - Chunking options
226+
* @param index - The chunk index
227+
* @param totalChunks - Total number of chunks
228+
* @returns Effect yielding the complete chunk with context
229+
*/
230+
export const attachContext = (
231+
text: RebuiltText,
232+
scopeTree: ScopeTree,
233+
options: ChunkOptions,
234+
index: number,
235+
totalChunks: number,
236+
): Effect.Effect<Chunk, ContextError> => {
237+
return Effect.try({
238+
try: () => {
239+
// Determine context mode
240+
const contextMode = options.contextMode ?? 'full'
241+
242+
// For 'none' mode, return minimal context
243+
if (contextMode === 'none') {
244+
const context: ChunkContext = {
245+
scope: [],
246+
entities: [],
247+
siblings: [],
248+
imports: [],
249+
}
250+
return {
251+
text: text.text,
252+
byteRange: text.byteRange,
253+
lineRange: text.lineRange,
254+
context,
255+
index,
256+
totalChunks,
257+
}
258+
}
259+
260+
// Get scope for this chunk's byte range
261+
const scope = getScopeForRange(text.byteRange, scopeTree)
262+
263+
// Get entities within the chunk
264+
const entities = getEntitiesInRange(text.byteRange, scopeTree)
265+
266+
// Get siblings based on options
267+
const siblingDetail = options.siblingDetail ?? 'signatures'
268+
const siblingOptions: SiblingOptions = {
269+
detail: siblingDetail,
270+
maxSiblings: contextMode === 'minimal' ? 2 : undefined,
271+
}
272+
const siblings = getSiblings(text.byteRange, scopeTree, siblingOptions)
273+
274+
// Get relevant imports
275+
const filterImports = options.filterImports ?? false
276+
const imports = getRelevantImports(entities, scopeTree, filterImports)
277+
278+
const context: ChunkContext = {
279+
scope,
280+
entities,
281+
siblings,
282+
imports,
283+
}
284+
285+
return {
286+
text: text.text,
287+
byteRange: text.byteRange,
288+
lineRange: text.lineRange,
289+
context,
290+
index,
291+
totalChunks,
292+
}
293+
},
294+
catch: (error: unknown) =>
295+
new ContextError('Failed to attach context', error),
296+
})
127297
}

0 commit comments

Comments
 (0)