diff --git a/biome.json b/biome.json index 3016cbe..8e30a60 100644 --- a/biome.json +++ b/biome.json @@ -28,5 +28,23 @@ "enabled": true, "useIgnoreFile": true, "clientKind": "git" - } + }, + "overrides": [ + { + "includes": ["test/**/*.ts"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noNonNullAssertedOptionalChain": "off" + }, + "correctness": { + "noUnsafeOptionalChaining": "off" + } + } + } + } + ] } diff --git a/src/chunk.ts b/src/chunk.ts index 40c40fa..061c1a8 100644 --- a/src/chunk.ts +++ b/src/chunk.ts @@ -1,4 +1,4 @@ -import { Effect } from 'effect' +import { Effect, Stream } from 'effect' import { chunk as chunkInternal, streamChunks as streamChunksInternal, @@ -7,7 +7,13 @@ import { extractEntities } from './extract' import { parseCode } from './parser' import { detectLanguage } from './parser/languages' import { buildScopeTree } from './scope' -import type { Chunk, ChunkOptions, Language } from './types' +import type { + Chunk, + ChunkOptions, + Language, + ParseResult, + ScopeTree, +} from './types' /** * Error thrown when chunking fails @@ -138,6 +144,114 @@ export async function chunk( return Effect.runPromise(chunkEffect(filepath, code, options)) } +/** + * Prepare the chunking pipeline (parse, extract, build scope tree) + * Returns the parsed result and scope tree needed for chunking + */ +const prepareChunking = ( + filepath: string, + code: string, + options?: ChunkOptions, +): Effect.Effect< + { parseResult: ParseResult; scopeTree: ScopeTree; language: Language }, + ChunkingError | UnsupportedLanguageError +> => { + return Effect.gen(function* () { + // Step 1: Detect language (or use override) + const language: Language | null = + options?.language ?? detectLanguage(filepath) + + if (!language) { + return yield* Effect.fail(new UnsupportedLanguageError(filepath)) + } + + // Step 2: Parse the code + const parseResult = yield* Effect.tryPromise({ + try: () => parseCode(code, language), + catch: (error: unknown) => + new ChunkingError('Failed to parse code', error), + }) + + // Step 3: Extract entities from AST + const entities = yield* Effect.mapError( + extractEntities(parseResult.tree.rootNode, language, code), + (error: unknown) => + new ChunkingError('Failed to extract entities', error), + ) + + // Step 4: Build scope tree + const scopeTree = yield* Effect.mapError( + buildScopeTree(entities), + (error: unknown) => + new ChunkingError('Failed to build scope tree', error), + ) + + return { parseResult, scopeTree, language } + }) +} + +/** + * Create an Effect Stream that yields chunks + * + * This is the Effect-native streaming API. Use this if you're working + * within the Effect ecosystem and want full composability. + * + * @param filepath - The file path (used for language detection) + * @param code - The source code to chunk + * @param options - Optional chunking configuration + * @returns Effect Stream of chunks with context + * + * @example + * ```ts + * import { chunkStreamEffect } from 'astchunk' + * import { Effect, Stream } from 'effect' + * + * const program = Stream.runForEach( + * chunkStreamEffect('src/utils.ts', sourceCode), + * (chunk) => Effect.log(chunk.text) + * ) + * + * Effect.runPromise(program) + * ``` + */ +export const chunkStreamEffect = ( + filepath: string, + code: string, + options?: ChunkOptions, +): Stream.Stream => { + return Stream.unwrap( + Effect.map(prepareChunking(filepath, code, options), (prepared) => { + const { parseResult, scopeTree, language } = prepared + + // Create stream from the internal generator + return Stream.fromAsyncIterable( + streamChunksInternal( + parseResult.tree.rootNode, + code, + scopeTree, + language, + options, + filepath, + ), + (error) => new ChunkingError('Stream iteration failed', error), + ).pipe( + // Attach parse error to chunks if present + Stream.map((chunk) => + parseResult.error + ? { + ...chunk, + context: { + ...chunk.context, + parseError: parseResult.error, + }, + } + : chunk, + ), + ) + }), + ) +} + /** * Stream source code chunks as they are generated * @@ -154,9 +268,9 @@ export async function chunk( * * @example * ```ts - * import { stream } from 'astchunk' + * import { chunkStream } from 'astchunk' * - * for await (const chunk of stream('src/utils.ts', sourceCode)) { + * for await (const chunk of chunkStream('src/utils.ts', sourceCode)) { * console.log(chunk.text, chunk.context) * } * ``` @@ -166,49 +280,14 @@ export async function* chunkStream( code: string, options?: ChunkOptions, ): AsyncGenerator { - // Detect language (or use override) - const language: Language | null = - options?.language ?? detectLanguage(filepath) - - if (!language) { - throw new UnsupportedLanguageError(filepath) - } - - // Parse the code - let parseResult: Awaited> - try { - parseResult = await parseCode(code, language) - } catch (error) { - throw new ChunkingError('Failed to parse code', error) - } - - // Extract entities from AST - let entities: Awaited< - ReturnType extends Effect.Effect - ? A - : never - > - try { - entities = await Effect.runPromise( - extractEntities(parseResult.tree.rootNode, language, code), - ) - } catch (error) { - throw new ChunkingError('Failed to extract entities', error) - } + // Prepare the chunking pipeline + const prepared = await Effect.runPromise( + prepareChunking(filepath, code, options), + ) - // Build scope tree - let scopeTree: Awaited< - ReturnType extends Effect.Effect - ? A - : never - > - try { - scopeTree = await Effect.runPromise(buildScopeTree(entities)) - } catch (error) { - throw new ChunkingError('Failed to build scope tree', error) - } + const { parseResult, scopeTree, language } = prepared - // Stream chunks from the internal generator, passing filepath for context + // Stream chunks from the internal generator const chunkGenerator = streamChunksInternal( parseResult.tree.rootNode, code, diff --git a/src/index.ts b/src/index.ts index 09a7ce5..4eeca9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { ChunkingError, chunk, chunkStream, + chunkStreamEffect, UnsupportedLanguageError, } from './chunk' diff --git a/test/chunking.test.ts b/test/chunking.test.ts index 5b54542..f3adc00 100644 --- a/test/chunking.test.ts +++ b/test/chunking.test.ts @@ -74,12 +74,12 @@ describe('chunk', () => { index: 0, totalChunks: 1, }) - expect(chunks[0]!.context).toMatchObject({ + expect(chunks[0]?.context).toMatchObject({ filepath: 'test.ts', language: 'typescript', }) - expect(chunks[0]!.context.entities).toHaveLength(1) - expect(chunks[0]!.context.entities[0]).toMatchObject({ + expect(chunks[0]?.context.entities).toHaveLength(1) + expect(chunks[0]?.context.entities[0]).toMatchObject({ name: 'greet', type: 'function', isPartial: false, @@ -159,7 +159,7 @@ function c() { return 3 }` // Even with wrong extension, should work with language override const chunks = await chunk('test.txt', code, { language: 'typescript' }) expect(chunks).toHaveLength(1) - expect(chunks[0]!.context.language).toBe('typescript') + expect(chunks[0]?.context.language).toBe('typescript') }) }) @@ -200,8 +200,8 @@ function d() { return 4 }` const chunks = await chunk('test.ts', code) expect(chunks).toHaveLength(1) - expect(chunks[0]!.byteRange).toEqual({ start: 0, end: 11 }) - expect(chunks[0]!.lineRange).toEqual({ start: 0, end: 0 }) + expect(chunks[0]?.byteRange).toEqual({ start: 0, end: 11 }) + expect(chunks[0]?.lineRange).toEqual({ start: 0, end: 0 }) }) test('exact line range verification with multiline code', async () => { @@ -215,8 +215,8 @@ function foo() { // Line 1 // All lines should be covered expect(chunks).toHaveLength(1) - expect(chunks[0]!.lineRange.start).toBe(0) - expect(chunks[0]!.lineRange.end).toBe(4) + expect(chunks[0]?.lineRange.start).toBe(0) + expect(chunks[0]?.lineRange.end).toBe(4) }) test('multiple chunks maintain byte continuity', async () => { @@ -249,7 +249,7 @@ function longFunction3() { const sortedChunks = [...chunks].sort( (a, b) => a.byteRange.start - b.byteRange.start, ) - expect(sortedChunks[0]!.byteRange.start).toBe(0) + expect(sortedChunks[0]?.byteRange.start).toBe(0) // Verify all byte ranges are valid for (const c of sortedChunks) { @@ -288,7 +288,7 @@ describe('context.entities verification', () => { // Find the class entity const classEntity = allEntities.find((e) => e.name === 'Calculator') expect(classEntity).toBeDefined() - expect(classEntity!.type).toBe('class') + expect(classEntity?.type).toBe('class') }) test('entity isPartial flag correctness', async () => { @@ -343,8 +343,8 @@ function add(a: number, b: number): number { .find((e) => e.name === 'add') expect(addEntity).toBeDefined() - expect(addEntity!.docstring).toBeDefined() - expect(addEntity!.docstring).toContain('Adds two numbers together') + expect(addEntity?.docstring).toBeDefined() + expect(addEntity?.docstring).toContain('Adds two numbers together') }) test('entity lineRange is present', async () => { @@ -353,11 +353,11 @@ function add(a: number, b: number): number { }` const chunks = await chunk('test.ts', code) - const entity = chunks[0]!.context.entities[0]! + const entity = chunks[0]?.context.entities[0]! expect(entity.lineRange).toBeDefined() - expect(entity.lineRange!.start).toBe(0) - expect(entity.lineRange!.end).toBe(2) + expect(entity.lineRange?.start).toBe(0) + expect(entity.lineRange?.end).toBe(2) }) }) @@ -388,7 +388,7 @@ describe('context.scope chain verification', () => { (s) => s.name === 'Outer', ) expect(outerScope).toBeDefined() - expect(outerScope!.type).toBe('class') + expect(outerScope?.type).toBe('class') } }) @@ -449,7 +449,7 @@ function fourth() { return 4 }` // Should have siblings before and after const beforeSiblings = siblings.filter((s) => s.position === 'before') - const afterSiblings = siblings.filter((s) => s.position === 'after') + const _afterSiblings = siblings.filter((s) => s.position === 'after') // first should be before second const firstSibling = beforeSiblings.find((s) => s.name === 'first') @@ -558,10 +558,10 @@ const x = foo() + bar()` const barImport = allImports.find((i) => i.name === 'bar') expect(fooImport).toBeDefined() - expect(fooImport!.source).toBe('./utils/foo') + expect(fooImport?.source).toBe('./utils/foo') expect(barImport).toBeDefined() - expect(barImport!.source).toBe('@scope/bar') + expect(barImport?.source).toBe('@scope/bar') }) }) @@ -586,7 +586,7 @@ function b() { return 2 }` language: 'typescript', }, }) - expect(chunks[0]!.text.length).toBeGreaterThan(0) + expect(chunks[0]?.text.length).toBeGreaterThan(0) }) test('stream respects options', async () => { @@ -652,8 +652,8 @@ describe('createChunker', () => { expect(chunks1).toHaveLength(1) expect(chunks2).toHaveLength(1) - expect(chunks1[0]!.text).toBe(code1) - expect(chunks2[0]!.text).toBe(code2) + expect(chunks1[0]?.text).toBe(code1) + expect(chunks2[0]?.text).toBe(code2) }) test('chunker can chunk multiple files with different extensions', async () => { @@ -667,8 +667,8 @@ describe('createChunker', () => { expect(tsChunks).toHaveLength(1) expect(jsChunks).toHaveLength(1) - expect(tsChunks[0]!.context.language).toBe('typescript') - expect(jsChunks[0]!.context.language).toBe('javascript') + expect(tsChunks[0]?.context.language).toBe('typescript') + expect(jsChunks[0]?.context.language).toBe('javascript') }) test('chunker.stream yields chunks with correct properties', async () => { @@ -879,8 +879,8 @@ const emoji = "๐ŸŽ‰๐Ÿš€โœจ"` expect(chunks).toHaveLength(1) // Should preserve unicode - expect(chunks[0]!.text).toContain('ใ“ใ‚“ใซใกใฏ') - expect(chunks[0]!.text).toContain('๐ŸŽ‰') + expect(chunks[0]?.text).toContain('ใ“ใ‚“ใซใกใฏ') + expect(chunks[0]?.text).toContain('๐ŸŽ‰') }) test('handles code with various comment styles', async () => { @@ -903,7 +903,7 @@ function documented() { .find((e) => e.name === 'documented') expect(funcEntity).toBeDefined() - expect(funcEntity!.docstring).toContain('JSDoc comment') + expect(funcEntity?.docstring).toContain('JSDoc comment') }) test('handles empty functions', async () => { @@ -930,7 +930,7 @@ function foo() { const chunks = await chunk('test.ts', code) expect(chunks).toHaveLength(1) - expect(chunks[0]!.text).toBe(code) + expect(chunks[0]?.text).toBe(code) }) }) diff --git a/test/extract.test.ts b/test/extract.test.ts index 07b2126..41e0fef 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -197,10 +197,10 @@ describe('extractEntities', () => { parent: null, }) // Class spans entire code - expect(cls!.byteRange.start).toBe(0) - expect(cls!.byteRange.end).toBe(code.length) - expect(cls!.lineRange.start).toBe(0) - expect(cls!.lineRange.end).toBe(8) + expect(cls?.byteRange.start).toBe(0) + expect(cls?.byteRange.end).toBe(code.length) + expect(cls?.lineRange.start).toBe(0) + expect(cls?.lineRange.end).toBe(8) const methods = entities.filter((e) => e.type === 'method') expect(methods).toHaveLength(2) @@ -1155,13 +1155,13 @@ describe('byte and line range verification', () => { expect(entities).toHaveLength(3) const cls = entities.find((e) => e.name === 'Foo') - expect(cls!.lineRange).toEqual({ start: 0, end: 3 }) + expect(cls?.lineRange).toEqual({ start: 0, end: 3 }) const bar = entities.find((e) => e.name === 'bar') - expect(bar!.lineRange).toEqual({ start: 1, end: 1 }) + expect(bar?.lineRange).toEqual({ start: 1, end: 1 }) const baz = entities.find((e) => e.name === 'baz') - expect(baz!.lineRange).toEqual({ start: 2, end: 2 }) + expect(baz?.lineRange).toEqual({ start: 2, end: 2 }) }) test('verifies byte ranges for multiple top-level functions', async () => { @@ -1180,15 +1180,15 @@ function b() {}` const fnB = entities.find((e) => e.name === 'b') // fnA should start at 0 - expect(fnA!.byteRange.start).toBe(0) + expect(fnA?.byteRange.start).toBe(0) // fnA should end before fnB starts - expect(fnA!.byteRange.end).toBeLessThanOrEqual(fnB!.byteRange.start) + expect(fnA?.byteRange.end).toBeLessThanOrEqual(fnB?.byteRange.start) // fnB should end at code.length - expect(fnB!.byteRange.end).toBe(code.length) + expect(fnB?.byteRange.end).toBe(code.length) // Verify line ranges - expect(fnA!.lineRange).toEqual({ start: 0, end: 0 }) - expect(fnB!.lineRange).toEqual({ start: 1, end: 1 }) + expect(fnA?.lineRange).toEqual({ start: 0, end: 0 }) + expect(fnB?.lineRange).toEqual({ start: 1, end: 1 }) }) test('verifies line range for multi-line interface', async () => { @@ -1233,13 +1233,13 @@ describe('parent relationship accuracy', () => { expect(entities).toHaveLength(3) const method1 = entities.find((e) => e.name === 'method1') - expect(method1!.parent).toBe('Container') + expect(method1?.parent).toBe('Container') const method2 = entities.find((e) => e.name === 'method2') - expect(method2!.parent).toBe('Container') + expect(method2?.parent).toBe('Container') const container = entities.find((e) => e.name === 'Container') - expect(container!.parent).toBeNull() + expect(container?.parent).toBeNull() }) test('top-level functions have null parent', async () => { @@ -1272,8 +1272,8 @@ function topLevel2() {}` // Python methods are extracted as 'function' type with parent set const method = entities.find((e) => e.name === 'my_method') - expect(method!.type).toBe('function') - expect(method!.parent).toBe('MyClass') + expect(method?.type).toBe('function') + expect(method?.parent).toBe('MyClass') }) }) @@ -1608,16 +1608,16 @@ import d from 'module-d'` expect(imports).toHaveLength(4) const importA = imports.find((i) => i.name === 'a') - expect(importA!.source).toBe('module-a') + expect(importA?.source).toBe('module-a') const importB = imports.find((i) => i.name === 'b') - expect(importB!.source).toBe('module-b') + expect(importB?.source).toBe('module-b') const importC = imports.find((i) => i.name === 'c') - expect(importC!.source).toBe('module-b') + expect(importC?.source).toBe('module-b') const importD = imports.find((i) => i.name === 'd') - expect(importD!.source).toBe('module-d') + expect(importD?.source).toBe('module-d') }) }) diff --git a/test/parser.test.ts b/test/parser.test.ts index 197beb5..d72d8ca 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -110,8 +110,8 @@ describe('parseCode', () => { // Verify function name via tree-sitter field access const nameNode = funcNode.childForFieldName('name') expect(nameNode).not.toBeNull() - expect(nameNode!.type).toBe('identifier') - expect(nameNode!.text).toBe('greet') + expect(nameNode?.type).toBe('identifier') + expect(nameNode?.text).toBe('greet') // Verify exact positions expect(funcNode.startPosition.row).toBe(0) @@ -122,7 +122,7 @@ describe('parseCode', () => { // Verify parameters field const paramsNode = funcNode.childForFieldName('parameters') expect(paramsNode).not.toBeNull() - expect(paramsNode!.type).toBe('formal_parameters') + expect(paramsNode?.type).toBe('formal_parameters') // Verify return type field const returnTypeNode = funcNode.childForFieldName('return_type') @@ -131,7 +131,7 @@ describe('parseCode', () => { // Verify body field const bodyNode = funcNode.childForFieldName('body') expect(bodyNode).not.toBeNull() - expect(bodyNode!.type).toBe('statement_block') + expect(bodyNode?.type).toBe('statement_block') }) test('parses arrow function with exact positions', async () => { @@ -151,7 +151,7 @@ describe('parseCode', () => { const arrowFunc = variableDeclarator.childForFieldName('value') expect(arrowFunc).not.toBeNull() - expect(arrowFunc!.type).toBe('arrow_function') + expect(arrowFunc?.type).toBe('arrow_function') // Verify positions expect(root.startPosition).toEqual({ row: 0, column: 0 }) @@ -177,14 +177,14 @@ describe('parseCode', () => { const className = classNode.childForFieldName('name') expect(className).not.toBeNull() - expect(className!.text).toBe('Calculator') + expect(className?.text).toBe('Calculator') const body = classNode.childForFieldName('body') expect(body).not.toBeNull() - expect(body!.type).toBe('class_body') + expect(body?.type).toBe('class_body') // Verify class body has exactly 2 members (field + method) - const namedChildren = body!.namedChildren + const namedChildren = body?.namedChildren expect(namedChildren).toHaveLength(2) expect(namedChildren[0].type).toBe('public_field_definition') expect(namedChildren[1].type).toBe('method_definition') @@ -206,14 +206,14 @@ describe('parseCode', () => { expect(interfaceNode.type).toBe('interface_declaration') const interfaceName = interfaceNode.childForFieldName('name') - expect(interfaceName!.text).toBe('User') + expect(interfaceName?.text).toBe('User') const body = interfaceNode.childForFieldName('body') expect(body).not.toBeNull() - expect(body!.type).toBe('interface_body') + expect(body?.type).toBe('interface_body') // Verify exact property count - const properties = body!.namedChildren.filter( + const properties = body?.namedChildren.filter( (n) => n.type === 'property_signature', ) expect(properties).toHaveLength(3) @@ -231,11 +231,11 @@ describe('parseCode', () => { expect(typeAlias.type).toBe('type_alias_declaration') const typeName = typeAlias.childForFieldName('name') - expect(typeName!.text).toBe('Status') + expect(typeName?.text).toBe('Status') const typeValue = typeAlias.childForFieldName('value') expect(typeValue).not.toBeNull() - expect(typeValue!.type).toBe('union_type') + expect(typeValue?.type).toBe('union_type') }) }) @@ -273,7 +273,7 @@ export { add }` const declarator = lexicalDecl.firstNamedChild! const pattern = declarator.childForFieldName('name') - expect(pattern!.type).toBe('object_pattern') + expect(pattern?.type).toBe('object_pattern') }) }) @@ -292,7 +292,7 @@ export { add }` expect(funcNode.type).toBe('function_definition') const funcName = funcNode.childForFieldName('name') - expect(funcName!.text).toBe('greet') + expect(funcName?.text).toBe('greet') // Verify exact position expect(funcNode.startPosition).toEqual({ row: 0, column: 0 }) @@ -319,14 +319,14 @@ export { add }` expect(classNode.type).toBe('class_definition') const className = classNode.childForFieldName('name') - expect(className!.text).toBe('Calculator') + expect(className?.text).toBe('Calculator') const body = classNode.childForFieldName('body') expect(body).not.toBeNull() - expect(body!.type).toBe('block') + expect(body?.type).toBe('block') // Verify method count - const methods = body!.namedChildren.filter( + const methods = body?.namedChildren.filter( (n) => n.type === 'function_definition', ) expect(methods).toHaveLength(2) @@ -373,7 +373,7 @@ def value(self): expect(funcNode.type).toBe('function_item') const funcName = funcNode.childForFieldName('name') - expect(funcName!.text).toBe('main') + expect(funcName?.text).toBe('main') // Verify positions expect(funcNode.startPosition).toEqual({ row: 0, column: 0 }) @@ -395,14 +395,14 @@ def value(self): expect(structNode.type).toBe('struct_item') const structName = structNode.childForFieldName('name') - expect(structName!.text).toBe('Point') + expect(structName?.text).toBe('Point') const body = structNode.childForFieldName('body') expect(body).not.toBeNull() - expect(body!.type).toBe('field_declaration_list') + expect(body?.type).toBe('field_declaration_list') // Verify field count - const fields = body!.namedChildren.filter( + const fields = body?.namedChildren.filter( (n) => n.type === 'field_declaration', ) expect(fields).toHaveLength(2) @@ -424,12 +424,12 @@ def value(self): expect(implNode.type).toBe('impl_item') const implType = implNode.childForFieldName('type') - expect(implType!.text).toBe('Point') + expect(implType?.text).toBe('Point') const body = implNode.childForFieldName('body') expect(body).not.toBeNull() - const methods = body!.namedChildren.filter( + const methods = body?.namedChildren.filter( (n) => n.type === 'function_item', ) expect(methods).toHaveLength(1) @@ -455,7 +455,7 @@ func main() { const funcNode = root.children[1] const funcName = funcNode.childForFieldName('name') - expect(funcName!.text).toBe('main') + expect(funcName?.text).toBe('main') // Verify positions expect(funcNode.startPosition).toEqual({ row: 2, column: 0 }) @@ -512,13 +512,13 @@ func (p Point) String() string { expect(classNode.type).toBe('class_declaration') const className = classNode.childForFieldName('name') - expect(className!.text).toBe('Main') + expect(className?.text).toBe('Main') const body = classNode.childForFieldName('body') expect(body).not.toBeNull() - expect(body!.type).toBe('class_body') + expect(body?.type).toBe('class_body') - const methods = body!.namedChildren.filter( + const methods = body?.namedChildren.filter( (n) => n.type === 'method_declaration', ) expect(methods).toHaveLength(1) @@ -526,7 +526,7 @@ func (p Point) String() string { // Verify method name const mainMethod = methods[0] const methodName = mainMethod.childForFieldName('name') - expect(methodName!.text).toBe('main') + expect(methodName?.text).toBe('main') }) test('parses interface correctly', async () => { @@ -543,7 +543,7 @@ func (p Point) String() string { expect(interfaceNode.type).toBe('interface_declaration') const interfaceName = interfaceNode.childForFieldName('name') - expect(interfaceName!.text).toBe('Comparable') + expect(interfaceName?.text).toBe('Comparable') }) }) @@ -560,9 +560,9 @@ func (p Point) String() string { // Verify error details expect(result.error).not.toBeNull() - expect(result.error!.recoverable).toBe(true) + expect(result.error?.recoverable).toBe(true) // Error message contains either ERROR or MISSING depending on grammar - expect(result.error!.message).toMatch(/ERROR|MISSING/) + expect(result.error?.message).toMatch(/ERROR|MISSING/) // Verify tree has errors expect(result.tree.rootNode.hasError).toBe(true) @@ -574,9 +574,9 @@ func (p Point) String() string { expect(result.tree).not.toBeNull() expect(result.error).not.toBeNull() - expect(result.error!.recoverable).toBe(true) + expect(result.error?.recoverable).toBe(true) // Error message should include position info - expect(result.error!.message).toMatch(/line \d+, column \d+/) + expect(result.error?.message).toMatch(/line \d+, column \d+/) }) test('Python: missing body produces partial tree', async () => { @@ -611,7 +611,7 @@ const b = 2` // Rust requires semicolons - this should have an error expect(result.tree).not.toBeNull() expect(result.error).not.toBeNull() - expect(result.error!.recoverable).toBe(true) + expect(result.error?.recoverable).toBe(true) }) test('Go: missing package declaration produces error', async () => { @@ -634,12 +634,12 @@ function c( { return }` const result = await parseCode(code, 'typescript') expect(result.error).not.toBeNull() - expect(result.error!.recoverable).toBe(true) + expect(result.error?.recoverable).toBe(true) // Should have multiple error locations (ERROR or MISSING) - expect(result.error!.message).toMatch(/ERROR|MISSING/) + expect(result.error?.message).toMatch(/ERROR|MISSING/) // Multiple errors means multiple occurrences of line info - const lineMatches = result.error!.message.match(/line \d+/g) - expect(lineMatches!.length).toBeGreaterThanOrEqual(2) + const lineMatches = result.error?.message.match(/line \d+/g) + expect(lineMatches?.length).toBeGreaterThanOrEqual(2) }) test('error count is capped at 3 plus summary', async () => { @@ -652,7 +652,7 @@ function e( { }` expect(result.error).not.toBeNull() // Error message should show first 3 errors and "... and X more" - expect(result.error!.message).toContain('more') + expect(result.error?.message).toContain('more') }) }) @@ -812,12 +812,12 @@ function c() {}` const result = await parseCode(code, 'typescript') const root = result.tree.rootNode - expect(root.firstChild!.type).toBe('function_declaration') - expect(root.lastChild!.type).toBe('function_declaration') + expect(root.firstChild?.type).toBe('function_declaration') + expect(root.lastChild?.type).toBe('function_declaration') // Verify first vs last by checking function names - const firstName = root.firstChild!.childForFieldName('name')!.text - const lastName = root.lastChild!.childForFieldName('name')!.text + const firstName = root.firstChild?.childForFieldName('name')?.text + const lastName = root.lastChild?.childForFieldName('name')?.text expect(firstName).toBe('a') expect(lastName).toBe('c') @@ -834,13 +834,13 @@ function c() {}` const second = first.nextSibling! const third = second.nextSibling! - expect(second.childForFieldName('name')!.text).toBe('b') - expect(third.childForFieldName('name')!.text).toBe('c') + expect(second.childForFieldName('name')?.text).toBe('b') + expect(third.childForFieldName('name')?.text).toBe('c') expect(third.nextSibling).toBeNull() // Use .equals() for node comparison instead of toBe (object identity) - expect(third.previousSibling!.equals(second)).toBe(true) - expect(second.previousSibling!.equals(first)).toBe(true) + expect(third.previousSibling?.equals(second)).toBe(true) + expect(second.previousSibling?.equals(first)).toBe(true) expect(first.previousSibling).toBeNull() }) @@ -855,8 +855,8 @@ function c() {}` const node = root.descendantForIndex(nameStart) expect(node).not.toBeNull() - expect(node!.text).toBe('name') - expect(node!.type).toBe('identifier') + expect(node?.text).toBe('name') + expect(node?.type).toBe('identifier') }) }) }) diff --git a/test/scope.test.ts b/test/scope.test.ts index b47c2f6..a324f6f 100644 --- a/test/scope.test.ts +++ b/test/scope.test.ts @@ -146,12 +146,14 @@ describe('buildScopeTreeFromEntities', () => { expect(classNode?.children[1]?.entity.name).toBe('subtract') // Verify method byte ranges are contained within class range - const classRange = classNode!.entity.byteRange - for (const child of classNode!.children) { - expect(child.entity.byteRange.start).toBeGreaterThanOrEqual( - classRange.start, - ) - expect(child.entity.byteRange.end).toBeLessThanOrEqual(classRange.end) + if (classNode) { + const classRange = classNode.entity.byteRange + for (const child of classNode.children) { + expect(child.entity.byteRange.start).toBeGreaterThanOrEqual( + classRange.start, + ) + expect(child.entity.byteRange.end).toBeLessThanOrEqual(classRange.end) + } } }) @@ -204,12 +206,16 @@ describe('buildScopeTreeFromEntities', () => { expect(classNode?.children).toHaveLength(3) // Verify ordering by byte position - for (let i = 1; i < classNode!.children.length; i++) { - const prevChild = classNode!.children[i - 1]! - const currChild = classNode!.children[i]! - expect(currChild.entity.byteRange.start).toBeGreaterThan( - prevChild.entity.byteRange.start, - ) + if (classNode) { + for (let i = 1; i < classNode.children.length; i++) { + const prevChild = classNode.children[i - 1] + const currChild = classNode.children[i] + if (prevChild && currChild) { + expect(currChild.entity.byteRange.start).toBeGreaterThan( + prevChild.entity.byteRange.start, + ) + } + } } // Verify exact order @@ -412,7 +418,7 @@ describe('findScopeAtOffset', () => { ) expect(addMethod).toBeDefined() const midpoint = Math.floor( - (addMethod!.byteRange.start + addMethod!.byteRange.end) / 2, + (addMethod?.byteRange.start + addMethod?.byteRange.end) / 2, ) const scope = findScopeAtOffset(tree, midpoint) @@ -433,7 +439,7 @@ describe('findScopeAtOffset', () => { // Find method's byte range const method = entities.find((e) => e.name === 'method') expect(method).toBeDefined() - const offset = method!.byteRange.start + 5 // Inside method + const offset = method?.byteRange.start + 5 // Inside method const scope = findScopeAtOffset(tree, offset) // Should find the method, not the class @@ -458,8 +464,8 @@ describe('findScopeAtOffset', () => { expect(methodEntity).toBeDefined() // Offset at start of class but before method - const offsetInClass = classEntity!.byteRange.start + 1 - if (offsetInClass < methodEntity!.byteRange.start) { + const offsetInClass = classEntity?.byteRange.start + 1 + if (offsetInClass < methodEntity?.byteRange.start) { const scope = findScopeAtOffset(tree, offsetInClass) expect(scope?.entity.name).toBe('Outer') } @@ -508,11 +514,11 @@ function second() { return 2 }` expect(second).toBeDefined() // At exact start of first function - const scopeAtStart = findScopeAtOffset(tree, first!.byteRange.start) + const scopeAtStart = findScopeAtOffset(tree, first?.byteRange.start) expect(scopeAtStart?.entity.name).toBe('first') // At exact start of second function - const scopeAtSecondStart = findScopeAtOffset(tree, second!.byteRange.start) + const scopeAtSecondStart = findScopeAtOffset(tree, second?.byteRange.start) expect(scopeAtSecondStart?.entity.name).toBe('second') }) }) @@ -653,7 +659,7 @@ describe('parent/child relationships', () => { expect(parentNode).toBeDefined() expect(parentNode?.children).toHaveLength(1) - const childNode = parentNode!.children[0] + const childNode = parentNode?.children[0] expect(childNode?.parent).toBe(parentNode) expect(childNode?.parent?.entity.name).toBe('Parent') }) @@ -735,8 +741,8 @@ describe('multi-language scope trees', () => { expect(cls?.children[1]?.entity.name).toBe('subtract') // Verify byte range containment - const classRange = cls!.entity.byteRange - for (const child of cls!.children) { + const classRange = cls?.entity.byteRange + for (const child of cls?.children) { expect(child.entity.byteRange.start).toBeGreaterThan(classRange.start) expect(child.entity.byteRange.end).toBeLessThanOrEqual(classRange.end) } @@ -852,7 +858,7 @@ function baz() { return 3 }` const barEntity = entities.find((e) => e.name === 'bar') expect(barEntity).toBeDefined() - const entitiesInRange = getEntitiesInRange(barEntity!.byteRange, tree) + const entitiesInRange = getEntitiesInRange(barEntity?.byteRange, tree) // Should find bar const bar = entitiesInRange.find((e) => e.name === 'bar') @@ -880,7 +886,7 @@ function baz() { return 3 }` const method2 = entities.find((e) => e.name === 'method2') expect(method2).toBeDefined() - const entitiesInRange = getEntitiesInRange(method2!.byteRange, tree) + const entitiesInRange = getEntitiesInRange(method2?.byteRange, tree) // method2 should not be partial (its full range is included) const m2 = entitiesInRange.find((e) => e.name === 'method2') @@ -914,8 +920,8 @@ function baz() { return 3 }` // Range that cuts through the function (starts at function start, ends before function end) const partialRange = { - start: fn!.byteRange.start, - end: fn!.byteRange.start + 20, + start: fn?.byteRange.start, + end: fn?.byteRange.start + 20, } const entitiesInRange = getEntitiesInRange(partialRange, tree) @@ -939,7 +945,7 @@ function documented() { const fn = entities.find((e) => e.name === 'documented') expect(fn).toBeDefined() - const entitiesInRange = getEntitiesInRange(fn!.byteRange, tree) + const entitiesInRange = getEntitiesInRange(fn?.byteRange, tree) const docFn = entitiesInRange.find((e) => e.name === 'documented') expect(docFn).toBeDefined()