diff --git a/__tests__/arkui-framework.test.ts b/__tests__/arkui-framework.test.ts new file mode 100644 index 000000000..cbe07bb65 --- /dev/null +++ b/__tests__/arkui-framework.test.ts @@ -0,0 +1,747 @@ +/** + * ArkUI Framework Resolver Tests + */ +import { describe, it, expect } from 'vitest'; +import { arkuiResolver } from '../src/resolution/frameworks/arkui'; + +describe('arkuiResolver.extract', () => { + it('extracts @Entry-decorated struct as arkui_page node', () => { + const src = ` +@Entry +@Component +struct IndexPage { + @State message: string = 'Hello'; + build() { + Column() { + Text(this.message) + } + } +} +`; + const { nodes, references } = arkuiResolver.extract!('pages/Index.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('arkui_page'); + expect(nodes[0].name).toBe('IndexPage'); + expect(nodes[0].language).toBe('arkts'); + expect(nodes[0].id).toContain('arkui_page:'); + expect(nodes[0].qualifiedName).toContain('IndexPage'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('IndexPage'); + expect(references[0].referenceKind).toBe('references'); + }); + + it('extracts @Entry struct without extra decorators', () => { + const src = ` +@Entry +struct SimplePage { + build() { + Text('simple'); + } +} +`; + const { nodes } = arkuiResolver.extract!('pages/SimplePage.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('arkui_page'); + expect(nodes[0].name).toBe('SimplePage'); + }); + + it('extracts multiple @Entry structs from one file', () => { + const src = ` +@Entry +@Component +struct PageA { + build() { Text('A'); } +} + +@Entry +struct PageB { + build() { Text('B'); } +} +`; + const { nodes } = arkuiResolver.extract!('pages/Multi.ets', src); + expect(nodes).toHaveLength(2); + expect(nodes.map((n) => n.name).sort()).toEqual(['PageA', 'PageB']); + expect(nodes.every((n) => n.kind === 'arkui_page')).toBe(true); + }); + + it('extracts @Component struct (without @Entry) as component node', () => { + const src = ` +@Component +struct NotAPage { + build() { + Text('not a page'); + } +} +`; + const { nodes, references } = arkuiResolver.extract!('pages/NotEntry.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('component'); + expect(nodes[0].name).toBe('NotAPage'); + expect(nodes[0].decorators).toEqual(['Component']); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('NotAPage'); + expect(references[0].referenceKind).toBe('references'); + }); + + it('extracts router.pushUrl as unresolved reference', () => { + const src = ` +@Entry +struct HomePage { + build() { + Button('Go Detail') + .onClick(() => { + router.pushUrl({ url: 'pages/Detail' }) + }) + } +} +`; + const { nodes, references } = arkuiResolver.extract!('pages/HomePage.ets', src); + expect(nodes).toHaveLength(1); + const navRefs = references.filter( + (r) => r.referenceKind === 'references' && r.referenceName !== 'HomePage' + ); + expect(navRefs).toHaveLength(1); + expect(navRefs[0].referenceName).toBe('pages/Detail'); + expect(navRefs[0].fromNodeId).toBe(nodes[0].id); + }); + + it('extracts router.replaceUrl as unresolved reference', () => { + const src = ` +@Entry +struct LoginPage { + build() { + Button('Login') + .onClick(() => { + router.replaceUrl({ url: 'pages/Home' }) + }) + } +} +`; + const { references } = arkuiResolver.extract!('pages/Login.ets', src); + const navRefs = references.filter((r) => r.referenceName !== 'LoginPage'); + expect(navRefs).toHaveLength(1); + expect(navRefs[0].referenceName).toBe('pages/Home'); + }); + + it('attributes pushUrl to nearest preceding @Entry struct', () => { + const src = ` +@Entry +struct PageOne { + build() { Text('one'); } +} + +router.pushUrl({ url: 'pages/PageTwo' }) + +@Entry +struct PageTwo { + build() { Text('two'); } +} +`; + const { nodes, references } = arkuiResolver.extract!('pages/RouteTest.ets', src); + expect(nodes).toHaveLength(2); + const navRef = references.find((r) => r.referenceName === 'pages/PageTwo'); + expect(navRef).toBeDefined(); + expect(navRef!.fromNodeId).toBe(nodes.find((n) => n.name === 'PageOne')!.id); + }); + + it('falls back to file-level id when no @Entry precedes pushUrl', () => { + const src = ` +router.pushUrl({ url: 'pages/Standalone' }) +`; + const { references } = arkuiResolver.extract!('pages/NopageRef.ets', src); + const navRef = references.find((r) => r.referenceName === 'pages/Standalone'); + expect(navRef).toBeDefined(); + expect(navRef!.fromNodeId).toMatch(/^file:/); + }); + + it('returns empty for non-.ets files', () => { + const { nodes, references } = arkuiResolver.extract!('test.ts', ''); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); + + it('skips // line-commented @Entry', () => { + const src = ` +// @Entry +// struct FakePage {} +@Entry +struct RealPage { + build() { Text('real'); } +} +`; + const { nodes } = arkuiResolver.extract!('pages/Commented.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('RealPage'); + }); + + it('skips /* block-commented */ @Entry', () => { + const src = ` +/* +@Entry +struct FakePage { + build() { Text('fake'); } +} +*/ +@Entry +struct RealPage { + build() { Text('real'); } +} +`; + const { nodes } = arkuiResolver.extract!('pages/BlockCommented.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('RealPage'); + }); + + it('does not duplicate @Entry+@Component struct as component', () => { + const src = ` +@Entry +@Component +struct IndexPage { + build() { + Text('hello'); + } +} +`; + const { nodes } = arkuiResolver.extract!('pages/Index.ets', src); + const pages = nodes.filter((n) => n.kind === 'arkui_page'); + const components = nodes.filter((n) => n.kind === 'component'); + expect(pages).toHaveLength(1); + expect(pages[0].name).toBe('IndexPage'); + expect(components).toHaveLength(0); + }); + + it('extracts @Component-only structs alongside @Entry pages', () => { + const src = ` +@Entry +@Component +struct HomePage { + build() { Text('home'); } +} + +@Component +struct MyButton { + build() { Button('click'); } +} + +@Component +struct MyLabel { + build() { Text('label'); } +} +`; + const { nodes } = arkuiResolver.extract!('pages/Mixed.ets', src); + const pages = nodes.filter((n) => n.kind === 'arkui_page'); + const components = nodes.filter((n) => n.kind === 'component'); + expect(pages).toHaveLength(1); + expect(pages[0].name).toBe('HomePage'); + expect(components).toHaveLength(2); + expect(components.map((c) => c.name).sort()).toEqual(['MyButton', 'MyLabel']); + components.forEach((c) => { + expect(c.decorators).toEqual(['Component']); + }); + }); + + it('extracts @Entry with routeName param', () => { + const src = ` +@Entry({ routeName: 'main' }) +@Component +struct MainPage { + build() { Text('main'); } +} +`; + const { nodes } = arkuiResolver.extract!('pages/Main.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('arkui_page'); + expect(nodes[0].name).toBe('MainPage'); + }); + + it('extracts @Component with freezeWhenInvisible param', () => { + const src = ` +@Component({ freezeWhenInvisible: true }) +struct FrozenLabel { + build() { Text('frozen'); } +} +`; + const { nodes, references } = arkuiResolver.extract!('pages/Frozen.ets', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('component'); + expect(nodes[0].name).toBe('FrozenLabel'); + expect(nodes[0].decorators).toEqual(['Component']); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('FrozenLabel'); + }); +}); + +describe('arkuiResolver.postExtract', () => { + it('returns empty array when main_pages.json is absent', () => { + const context = { + readFile: (_path: string) => null, + getNodesByKind: (_kind: string) => [], + }; + const result = arkuiResolver.postExtract!(context as any); + expect(result).toEqual([]); + }); + + it('creates arkui_page nodes from main_pages.json src entries', () => { + const json = JSON.stringify({ src: ['pages/Index', 'pages/Detail'] }); + const context = { + readFile: (path: string) => { + if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null; + if (path === 'main_pages.json') return json; + return null; + }, + getNodesByKind: (_kind: string) => [] as any[], + }; + const result = arkuiResolver.postExtract!(context as any); + expect(result).toHaveLength(2); + expect(result[0].kind).toBe('arkui_page'); + expect(result[0].name).toBe('pages/Index'); + expect(result[1].name).toBe('pages/Detail'); + }); + + it('de-duplicates against already extracted pages (filePath match)', () => { + const json = JSON.stringify({ src: ['pages/Index', 'pages/Detail'] }); + const context = { + readFile: (path: string) => { + if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null; + if (path === 'main_pages.json') return json; + return null; + }, + getNodesByKind: (_kind: string) => [ + { + filePath: 'pages/Index.ets', + qualifiedName: 'pages/Index.ets::Index', + name: 'Index', + }, + ] as any[], + }; + const result = arkuiResolver.postExtract!(context as any); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('pages/Detail'); + }); + + it('de-duplicates against qualifiedName end-match', () => { + const json = JSON.stringify({ src: ['pages/Detail'] }); + const context = { + readFile: (path: string) => { + if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null; + if (path === 'main_pages.json') return json; + return null; + }, + getNodesByKind: (_kind: string) => [ + { + filePath: 'pages/Detail.ets', + qualifiedName: 'entry/src/main/ets/pages/Detail.ets::Detail', + name: 'Detail', + }, + ] as any[], + }; + const result = arkuiResolver.postExtract!(context as any); + expect(result).toEqual([]); + }); + + it('prefers primary config path over fallback', () => { + const primary = JSON.stringify({ src: ['pages/Primary'] }); + const fallback = JSON.stringify({ src: ['pages/Fallback'] }); + const context = { + readFile: (path: string) => { + if (path === 'entry/src/main/resources/base/profile/main_pages.json') return primary; + if (path === 'main_pages.json') return fallback; + return null; + }, + getNodesByKind: (_kind: string) => [] as any[], + }; + const result = arkuiResolver.postExtract!(context as any); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('pages/Primary'); + }); +}); + +describe('arkuiResolver.resolve', () => { + it('resolves pages/Detail to matching arkui_page by filePath', () => { + const context = { + getNodesByKind: (_kind: string) => [ + { + id: 'page1', + filePath: 'entry/src/main/ets/pages/Detail.ets', + qualifiedName: 'entry/src/main/ets/pages/Detail.ets::Detail', + name: 'Detail', + }, + ], + }; + const ref = { + fromNodeId: 'caller1', + referenceName: 'pages/Detail', + referenceKind: 'references' as const, + line: 10, + column: 0, + }; + const result = arkuiResolver.resolve!(ref as any, context as any); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe('page1'); + expect(result!.confidence).toBe(0.9); + }); + + it('resolves pages/Detail to index.ets fallback', () => { + const context = { + getNodesByKind: (_kind: string) => [ + { + id: 'page2', + filePath: 'entry/src/main/ets/pages/Detail/index.ets', + qualifiedName: 'entry/src/main/ets/pages/Detail/index.ets::Detail', + name: 'Detail', + }, + ], + }; + const ref = { + fromNodeId: 'caller2', + referenceName: 'pages/Detail', + referenceKind: 'references' as const, + line: 10, + column: 0, + }; + const result = arkuiResolver.resolve!(ref as any, context as any); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe('page2'); + expect(result!.confidence).toBe(0.9); + }); + + it('falls back to partial path match with lower confidence', () => { + const context = { + getNodesByKind: (_kind: string) => [ + { + id: 'page3', + filePath: 'feature/src/main/ets/custom/Detail.ets', + qualifiedName: 'feature/src/main/ets/custom/Detail.ets::Detail', + name: 'Detail', + }, + ], + }; + const ref = { + fromNodeId: 'caller3', + referenceName: 'pages/Detail', + referenceKind: 'references' as const, + line: 10, + column: 0, + }; + const result = arkuiResolver.resolve!(ref as any, context as any); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe('page3'); + expect(result!.confidence).toBeGreaterThanOrEqual(0.65); + expect(result!.confidence).toBeLessThan(0.9); + }); + + it('returns null for non-page references', () => { + const context = { getNodesByKind: (_kind: string) => [] }; + const ref = { + fromNodeId: 'caller4', + referenceName: 'SomeUtility', + referenceKind: 'references' as const, + line: 10, + column: 0, + }; + const result = arkuiResolver.resolve!(ref as any, context as any); + expect(result).toBeNull(); + }); + + it('returns null for pages/ reference with no matching nodes', () => { + const context = { getNodesByKind: (_kind: string) => [] }; + const ref = { + fromNodeId: 'caller5', + referenceName: 'pages/NotFound', + referenceKind: 'references' as const, + line: 10, + column: 0, + }; + const result = arkuiResolver.resolve!(ref as any, context as any); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Callback-synthesizer phase tests +// --------------------------------------------------------------------------- +import type { Node, Edge } from '../src/types'; +import { arkuiStateChainEdges, arkuiStateDepEdges, arkuiEventChainEdges } from '../src/resolution/callback-synthesizer'; + +/** Create a minimal QueryBuilder mock. */ +function mockQueries(overrides: { + classes?: Node[]; + structs?: Node[]; + edges?: Map; + nodesById?: Map; +} = {}) { + const classes = overrides.classes ?? []; + const structs = overrides.structs ?? []; + const edges = overrides.edges ?? new Map(); + const nodesById = overrides.nodesById ?? new Map(); + return { + getNodesByKind: (kind: string) => { + if (kind === 'class') return classes; + if (kind === 'struct') return structs; + return []; + }, + getOutgoingEdges: (nodeId: string, _kinds: string[]) => edges.get(nodeId) ?? [], + getNodeById: (id: string) => nodesById.get(id) ?? null, + } as any; +} + +/** Create a minimal ResolutionContext mock. */ +function mockCtx(overrides: { + files?: string[]; + fileContents?: Map; + fileNodes?: Map; +} = {}) { + const files = overrides.files ?? []; + const fileContents = overrides.fileContents ?? new Map(); + const fileNodes = overrides.fileNodes ?? new Map(); + return { + getAllFiles: () => files, + readFile: (path: string) => fileContents.get(path) ?? null, + getNodesInFile: (path: string) => fileNodes.get(path) ?? [], + } as any; +} + +describe('arkuiStateChainEdges', () => { + it('links every sibling method to build() in .ets structs', () => { + const buildNode: Node = { + id: 'build-1', kind: 'method', name: 'build', + filePath: 'pages/Index.ets', startLine: 20, endLine: 30, + qualifiedName: 'pages/Index.ets::Index::build', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const onClickNode: Node = { + id: 'onClick-1', kind: 'method', name: 'onClick', + filePath: 'pages/Index.ets', startLine: 10, endLine: 18, + qualifiedName: 'pages/Index.ets::Index::onClick', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const structNode: Node = { + id: 'struct-1', kind: 'struct', name: 'Index', + filePath: 'pages/Index.ets', startLine: 1, endLine: 35, + qualifiedName: 'pages/Index.ets::Index', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const nodesById = new Map(); + nodesById.set('build-1', buildNode); + nodesById.set('onClick-1', onClickNode); + const edges = new Map(); + edges.set('struct-1', [ + { source: 'struct-1', target: 'build-1', kind: 'contains', line: 20 }, + { source: 'struct-1', target: 'onClick-1', kind: 'contains', line: 10 }, + ] as any); + const queries = mockQueries({ + structs: [structNode], + edges, + nodesById, + }); + const ctx = mockCtx(); + + const result = arkuiStateChainEdges(queries as any, ctx as any); + expect(result).toHaveLength(1); + expect(result[0].source).toBe('onClick-1'); + expect(result[0].target).toBe('build-1'); + expect(result[0].kind).toBe('calls'); + expect(result[0].provenance).toBe('heuristic'); + expect(result[0].metadata?.synthesizedBy).toBe('arkui-state-chain'); + }); + + it('skips non-.ets files', () => { + const clsNode: Node = { + id: 'cls-ts', kind: 'class', name: 'Foo', + filePath: 'utils.ts', startLine: 0, endLine: 10, + qualifiedName: 'utils.ts::Foo', language: 'typescript', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const queries = mockQueries({ classes: [clsNode] }); + const result = arkuiStateChainEdges(queries as any, mockCtx() as any); + expect(result).toEqual([]); + }); +}); + +describe('arkuiStateDepEdges', () => { + it('links methods that read @State properties → property nodes', () => { + // Source: @State at line 5, build() body spans 5-7, struct covers 1-8. + const buildNode: Node = { + id: 'build-2', kind: 'method', name: 'build', + filePath: 'pages/Home.ets', startLine: 5, endLine: 7, + qualifiedName: 'pages/Home.ets::Home::build', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const countProp: Node = { + id: 'prop-count', kind: 'property', name: 'count', + filePath: 'pages/Home.ets', startLine: 5, endLine: 5, + qualifiedName: 'pages/Home.ets::Home::count', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const structNode: Node = { + id: 'struct-2', kind: 'struct', name: 'Home', + filePath: 'pages/Home.ets', startLine: 1, endLine: 8, + qualifiedName: 'pages/Home.ets::Home', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const nodesById = new Map(); + nodesById.set('build-2', buildNode); + const edges = new Map(); + edges.set('struct-2', [ + { source: 'struct-2', target: 'build-2', kind: 'contains', line: 5 }, + ] as any); + const queries = mockQueries({ structs: [structNode], edges, nodesById }); + + const src = ` +@Entry +@Component +struct Home { + @State count: number = 0; + build() { + Text(this.count.toString()); + } +} +`; + const fileContents = new Map(); + fileContents.set('pages/Home.ets', src); + const fileNodes = new Map(); + fileNodes.set('pages/Home.ets', [structNode, buildNode, countProp]); + const ctx = mockCtx({ + files: ['pages/Home.ets'], + fileContents, + fileNodes, + }); + + const result = arkuiStateDepEdges(queries as any, ctx as any); + expect(result.length).toBeGreaterThanOrEqual(1); + // build() reads this.count → edge: build → count property + const buildEdge = result.find((e) => e.source === 'build-2'); + expect(buildEdge).toBeDefined(); + expect(buildEdge!.target).toBe('prop-count'); + expect(buildEdge!.kind).toBe('calls'); + expect(buildEdge!.provenance).toBe('heuristic'); + expect(buildEdge!.metadata?.synthesizedBy).toBe('arkui-state-dep'); + }); + + it('does not link methods that do not reference the state property', () => { + // Source: @State at 5, helper at 6, build at 7, struct covers 1-7. + const helperNode: Node = { + id: 'helper-1', kind: 'method', name: 'helper', + filePath: 'pages/Home.ets', startLine: 6, endLine: 6, + qualifiedName: 'pages/Home.ets::Home::helper', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const countProp: Node = { + id: 'prop-count-2', kind: 'property', name: 'count', + filePath: 'pages/Home.ets', startLine: 5, endLine: 5, + qualifiedName: 'pages/Home.ets::Home::count', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const structNode: Node = { + id: 'struct-3', kind: 'struct', name: 'Home', + filePath: 'pages/Home.ets', startLine: 1, endLine: 7, + qualifiedName: 'pages/Home.ets::Home', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const nodesById = new Map(); + nodesById.set('helper-1', helperNode); + const edges = new Map(); + edges.set('struct-3', [ + { source: 'struct-3', target: 'helper-1', kind: 'contains', line: 6 }, + ] as any); + const queries = mockQueries({ structs: [structNode], edges, nodesById }); + + const src = ` +@Entry +@Component +struct Home { + @State count: number = 0; + helper() { return 42; } + build() { Text('hello'); } +} +`; + const fileContents = new Map(); + fileContents.set('pages/Home.ets', src); + const fileNodes = new Map(); + fileNodes.set('pages/Home.ets', [structNode, helperNode, countProp]); + const ctx = mockCtx({ + files: ['pages/Home.ets'], + fileContents, + fileNodes, + }); + + const result = arkuiStateDepEdges(queries as any, ctx as any); + const helperEdge = result.find((e) => e.source === 'helper-1'); + expect(helperEdge).toBeUndefined(); + }); +}); + +describe('arkuiEventChainEdges', () => { + it('links build() → handler for .onClick(this.handler)', () => { + // Source is 7 lines (1-indexed): handleClick at 3, build() body spans 4-6. + const buildNode: Node = { + id: 'build-3', kind: 'method', name: 'build', + filePath: 'pages/Click.ets', startLine: 4, endLine: 6, + qualifiedName: 'pages/Click.ets::Page::build', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const handlerNode: Node = { + id: 'handleClick-1', kind: 'method', name: 'handleClick', + filePath: 'pages/Click.ets', startLine: 3, endLine: 3, + qualifiedName: 'pages/Click.ets::Page::handleClick', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const src = ` +@Entry +struct Page { + handleClick() { console.log('clicked'); } + build() { + Button('OK').onClick(() => { this.handleClick(); }); + } +} +`; + const fileContents = new Map(); + fileContents.set('pages/Click.ets', src); + const fileNodes = new Map(); + fileNodes.set('pages/Click.ets', [buildNode, handlerNode]); + const ctx = mockCtx({ + files: ['pages/Click.ets'], + fileContents, + fileNodes, + }); + + const result = arkuiEventChainEdges(ctx as any); + expect(result).toHaveLength(1); + expect(result[0].source).toBe('build-3'); + expect(result[0].target).toBe('handleClick-1'); + expect(result[0].kind).toBe('calls'); + expect(result[0].provenance).toBe('heuristic'); + expect(result[0].metadata?.synthesizedBy).toBe('arkui-event-chain'); + expect(result[0].metadata?.handler).toBe('handleClick'); + }); + + it('skips refs where handler name is build', () => { + // Source: build() body spans lines 3-5. + const buildNode: Node = { + id: 'build-4', kind: 'method', name: 'build', + filePath: 'pages/Rec.ets', startLine: 3, endLine: 5, + qualifiedName: 'pages/Rec.ets::Page::build', language: 'arkts', + startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const src = ` +@Entry +struct Page { + build() { + Column() { this.build(); } + } +} +`; + const fileContents = new Map(); + fileContents.set('pages/Rec.ets', src); + const fileNodes = new Map(); + fileNodes.set('pages/Rec.ets', [buildNode]); + const ctx = mockCtx({ + files: ['pages/Rec.ets'], + fileContents, + fileNodes, + }); + + const result = arkuiEventChainEdges(ctx as any); + expect(result).toEqual([]); + }); +}); diff --git a/src/context/index.ts b/src/context/index.ts index 3d19c65d8..b7fdd763d 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -158,7 +158,7 @@ const DEFAULT_BUILD_OPTIONS: Required = { */ const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait', - 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', + 'component', 'route', 'arkui_page', 'variable', 'constant', 'enum', 'module', 'namespace', ]; /** @@ -388,6 +388,12 @@ export class ContextBuilder { ? `renders <${String(m.via || 'child')}>` : m.synthesizedBy === 'vue-handler' ? `Vue @${String(m.event || 'event')} handler` + : m.synthesizedBy === 'arkui-state-chain' + ? `state chain via ${m.via ? `\`${String(m.via)}\`` : 'method'}${at}` + : m.synthesizedBy === 'arkui-state-dep' + ? `reads ${m.decorator ? `\`${String(m.decorator)}\`` : '@State'} ${String(m.property || 'prop')}` + : m.synthesizedBy === 'arkui-event-chain' + ? `event ${m.event ? `\`${String(m.event)}\`` : ''} → ${m.handler ? `\`${String(m.handler)}\`` : 'handler'}${at}` : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`; synthByPair.set(`${e.source}>${e.target}`, label); } diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..50ba326b4 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + arkts: 'tree-sitter-arkts.wasm', }; /** @@ -49,6 +50,7 @@ export const EXTENSION_MAP: Record = { // ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366) '.mts': 'typescript', '.cts': 'typescript', + '.ets': 'arkts', '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', @@ -185,7 +187,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + arkts: arktsExtractor, }; diff --git a/src/extraction/wasm/tree-sitter-arkts.wasm b/src/extraction/wasm/tree-sitter-arkts.wasm new file mode 100644 index 000000000..95b6d63c0 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-arkts.wasm differ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index fc184132e..8b85f9bc4 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1262,6 +1262,32 @@ export class ToolHandler { registeredAt, }; } + if (m?.synthesizedBy === 'arkui-state-chain') { + const via = m.via ? `\`${String(m.via)}\`` : 'sibling method'; + return { + label: `ArkUI state chain — ${via} triggers build() re-render (dynamic dispatch)`, + compact: `dynamic: ArkUI state chain via ${via}${at}`, + registeredAt, + }; + } + if (m?.synthesizedBy === 'arkui-state-dep') { + const decorator = m.decorator ? `\`${String(m.decorator)}\`` : '@State'; + const prop = m.property ? String(m.property) : 'property'; + return { + label: `ArkUI state dep — reads ${decorator} ${prop} (dynamic dispatch)`, + compact: `dynamic: ArkUI reads ${decorator} ${prop}${at}`, + registeredAt, + }; + } + if (m?.synthesizedBy === 'arkui-event-chain') { + const ev = m.event ? `\`${String(m.event)}\`` : 'an event'; + const handler = m.handler ? `\`${String(m.handler)}\`` : 'handler'; + return { + label: `ArkUI event chain — .on${String(m.event || 'Event')}() → ${handler} (dynamic dispatch)`, + compact: `dynamic: ArkUI ${ev} → ${handler}${at}`, + registeredAt, + }; + } return null; } diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 80294672f..1db663568 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1183,11 +1183,218 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext): return edges; } +// ============================================================================= +// ArkUI (HarmonyOS) synthesis phases +// ============================================================================= +// ArkUI structs are tree-sitter-parsed as `class` nodes in .ets files. Each +// struct with a `build()` method is a component; inside it, `@State`/`@Prop`/ +// `@Link` properties drive re-renders, `@Builder` methods produce sub-trees, +// and event bindings (`.onClick()`, `.onChange()`) invoke handlers. +// Three phases close the static gap between these declaration-time concepts +// and the runtime flow that tree-sitter can't see. + +/** ArkUI file extensions handled by these phases. */ +const ARKUI_EXT_RE = /\.ets$/; + +/** Regex for ArkUI reactive state decorators: @State / @Prop / @Link / @StorageLink / @Provide / @Consume. */ +const ARKUI_STATE_RE = /@(?:State|Prop|Link|StorageLink|StorageProp|Provide|Consume)\s*(?:\([^)]*\))?\s*(\w+)\s*[:=(]/g; + +/** Regex for ArkUI event bindings inside build(): .onClick(...), .onChange(...), etc. */ +const ARKUI_HANDLER_RE = /\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\([\s\S]*?this\.(\w+)/g; + +// --- Phase A: ArkUI state-chain edges ------------------------------------------ + +/** + * Phase A: ArkUI state-chain edges. + * + * In ArkUI, `@State`/`@Prop` mutation triggers a re-render of `build()`. + * The framework-internal re-render hop is invisible to static analysis, so + * a flow like "onClick → this.count++ → rebuilt UI" dead-ends at the state + * mutation. Bridge it: for each ArkUI struct (class in .ets) with a `build()` + * method, link every sibling method → `build()`. + */ +export function arkuiStateChainEdges(queries: QueryBuilder, _ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + // ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets + // files (libraries, helpers) are kind 'class'. Query both. + for (const kind of ['class', 'struct'] as const) { + for (const cls of queries.getNodesByKind(kind)) { + if (!ARKUI_EXT_RE.test(cls.filePath)) continue; + const children = queries.getOutgoingEdges(cls.id, ['contains']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n && n.kind === 'method'); + const build = children.find((n) => n.name === 'build'); + if (!build) continue; + let added = 0; + for (const m of children) { + if (added >= MAX_CALLBACKS_PER_CHANNEL) break; + if (m.id === build.id) continue; + const key = `${m.id}>${build.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: m.id, target: build.id, kind: 'calls', line: m.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-state-chain', via: m.name, registeredAt: `${build.filePath}:${build.startLine}` }, + }); + added++; + } + } + } + return edges; +} + +// --- Phase B: ArkUI state-dependency edges ------------------------------------ + +/** + * Phase B: ArkUI state-dependency edges. + * + * `@State`/`@Prop`/`@Link`/`@StorageLink`/`@Provide`/`@Consume` properties + * are the reactive primitives that drive ArkUI re-renders. When a `@Builder` + * method or regular method reads `this.`, link the method → the + * state property so data-flow traces show which state each method depends on. + */ +export function arkuiStateDepEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + + // Build a map: structId/classId → methods for quick lookup. + const classMethods = new Map(); + // ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets + // files (libraries, helpers) are kind 'class'. Query both. + for (const kind of ['class', 'struct'] as const) { + for (const cls of queries.getNodesByKind(kind)) { + if (!ARKUI_EXT_RE.test(cls.filePath)) continue; + const children = queries.getOutgoingEdges(cls.id, ['contains']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n && n.kind === 'method'); + if (children.length > 0) classMethods.set(cls.id, { methods: children }); + } + } + if (classMethods.size === 0) return edges; + + for (const file of ctx.getAllFiles()) { + if (!ARKUI_EXT_RE.test(file)) continue; + const content = ctx.readFile(file); + if (!content) continue; + + const classScopes = ctx.getNodesInFile(file) + .filter((n) => (n.kind === 'class' || n.kind === 'struct') && classMethods.has(n.id)) + .map((c) => ({ id: c.id, start: c.startLine, end: c.endLine })); + + const safe = stripCommentsForRegex(content, 'typescript'); + ARKUI_STATE_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ARKUI_STATE_RE.exec(safe))) { + const propName = m[1]!; + const line = safe.slice(0, m.index).split('\n').length; + + // Find which class scope this property belongs to. + let classId: string | null = null; + for (const scope of classScopes) { + if (line >= scope.start && line <= scope.end) { + classId = scope.id; + break; + } + } + if (!classId) continue; + + // Find the property node for this @State/@Prop/@Link property. + const propNode = ctx.getNodesInFile(file).find( + (n) => n.kind === 'property' && n.name === propName && + n.startLine >= line && n.startLine <= line + 2 + ); + if (!propNode) continue; + + const cm = classMethods.get(classId); + if (!cm) continue; + + // For each method in this struct, check if it reads this.. + const refRe = new RegExp( + `this\\.${propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b` + ); + for (const method of cm.methods) { + const methodSrc = sliceLines(content, method.startLine, method.endLine); + if (!methodSrc || !refRe.test(methodSrc)) continue; + + const key = `${method.id}>${propNode.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: method.id, target: propNode.id, kind: 'calls', line: method.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-state-dep', decorator: m[0]!.split(/\s+/)[0]!, property: propName }, + }); + } + } + } + return edges; +} + +// --- Phase C: ArkUI event-chain edges ----------------------------------------- + +/** + * Phase C: ArkUI event-chain edges. + * + * ArkUI `build()` bodies declare event bindings like + * `Button('OK').onClick(() => { this.handleOK() })`. The `handleOK` method + * is reachable only at runtime through the framework event system — no static + * call edge exists. Bridge it: for each `.ets` file, scan the body of every + * `build()` method for `.onXxx(this.handler)` patterns and link + * `build() → handlerMethod`. + */ +export function arkuiEventChainEdges(ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + + for (const file of ctx.getAllFiles()) { + if (!ARKUI_EXT_RE.test(file)) continue; + const content = ctx.readFile(file); + if (!content || !/\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\(/.test(content)) continue; + + const nodes = ctx.getNodesInFile(file); + const buildMethods = nodes.filter( + (n) => n.kind === 'method' && n.name === 'build' && ARKUI_EXT_RE.test(n.filePath) + ); + if (buildMethods.length === 0) continue; + + for (const build of buildMethods) { + const src = sliceLines(content, build.startLine, build.endLine); + if (!src) continue; + + ARKUI_HANDLER_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ARKUI_HANDLER_RE.exec(src))) { + const handlerName = m[1]!; + if (handlerName === 'build') continue; + + // Resolve handler to a method in the same file. + const handler = nodes.find( + (n) => n.kind === 'method' && n.name === handlerName + ); + if (!handler || handler.id === build.id) continue; + + const key = `${build.id}>${handler.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: build.id, target: handler.id, kind: 'calls', line: build.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-event-chain', event: m[0]!.match(/\.on(\w+)/)?.[1] ?? '', handler: handlerName }, + }); + } + } + } + return edges; +} + /** * Synthesize dispatcher→callback edges (field observers + EventEmitters + - * React re-render + JSX children + Vue templates + RN event channel + - * Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the - * count added. Never throws into indexing — callers wrap in try/catch. + * React re-render + JSX children + Vue templates + ArkUI phases + RN event + * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). + * Returns the count added. Never throws into indexing — callers wrap in + * try/catch. */ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number { const fieldEdges = fieldChannelEdges(queries, ctx); @@ -1204,6 +1411,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const fabricNativeEdges = fabricNativeImplEdges(ctx); const mybatisEdges = mybatisJavaXmlEdges(queries); const ginEdges = ginMiddlewareChainEdges(queries, ctx); + const arkuiStateChainE = arkuiStateChainEdges(queries, ctx); + const arkuiStateE = arkuiStateDepEdges(queries, ctx); + const arkuiEventChainE = arkuiEventChainEdges(ctx); const merged: Edge[] = []; const seen = new Set(); @@ -1222,6 +1432,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...fabricNativeEdges, ...mybatisEdges, ...ginEdges, + ...arkuiStateChainE, + ...arkuiStateE, + ...arkuiEventChainE, ]) { const key = `${e.source}>${e.target}`; if (seen.has(key)) continue; diff --git a/src/resolution/frameworks/arkui.ts b/src/resolution/frameworks/arkui.ts new file mode 100644 index 000000000..4cb8665dd --- /dev/null +++ b/src/resolution/frameworks/arkui.ts @@ -0,0 +1,301 @@ +/** + * ArkUI Framework Resolver (HarmonyOS) + * + * Handles ArkUI declarative UI constructs in .ets files: + * - @Entry-decorated structs → arkui_page nodes + * - router.pushUrl / router.replaceUrl → unresolved references + * - main_pages.json → cross-file page registration + * + * Regex-over-source approach (comment-stripped), matching the + * NestJS/Express pattern. ArkTS is a TypeScript superset. + */ + +import { Node } from '../../types'; +import { + FrameworkResolver, + UnresolvedRef, + ResolvedRef, + ResolutionContext, +} from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; + +export const arkuiResolver: FrameworkResolver = { + name: 'arkui', + languages: ['arkts'], + + // ------------------------------------------------------------------ + // detect + // ------------------------------------------------------------------ + detect(context: ResolutionContext): boolean { + // build-profile.json5 is the canonical HarmonyOS/ArkUI project marker. + if (context.fileExists('build-profile.json5')) return true; + + // Fallback: any .ets file with an @Entry decorator. + for (const file of context.getAllFiles()) { + if (!file.endsWith('.ets')) continue; + const content = context.readFile(file); + if (content && /@Entry\b/.test(content)) return true; + } + return false; + }, + + // ------------------------------------------------------------------ + // resolve + // ------------------------------------------------------------------ + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Only handle navigation references whose referenceName is a page URL + // (e.g. "pages/Detail", "entry/src/main/ets/pages/Detail"). + const routePath = ref.referenceName; + if ( + !routePath.startsWith('pages/') && + !routePath.startsWith('entry/src/main/ets/') + ) { + return null; + } + + // Match against arkui_page nodes. + let best: Node | null = null; + let bestScore = 0; + + for (const node of context.getNodesByKind('arkui_page')) { + // Exact file-path match (highest confidence). + // Anchor with a leading '/' so `pages/Detail` matches + // `entry/.../pages/Detail.ets` but NOT + // `feature/.../custom/Detail.ets`. + const pathSuffix = `/${routePath}.ets`; + const indexPathSuffix = `/${routePath}/index.ets`; + if ( + node.filePath.endsWith(pathSuffix) || + node.filePath.endsWith(indexPathSuffix) + ) { + return { + original: ref, + targetNodeId: node.id, + confidence: 0.9, + resolvedBy: 'framework', + }; + } + + // Partial path match. + if (node.qualifiedName.includes(routePath)) { + const score = 0.7; + if (score > bestScore) { + best = node; + bestScore = score; + } + } + + // Name-based fallback — last path segment matches page name. + const pageName = routePath.split('/').pop()!; + if (node.name === pageName) { + const score = 0.65; + if (score > bestScore) { + best = node; + bestScore = score; + } + } + } + + if (best) { + return { + original: ref, + targetNodeId: best.id, + confidence: bestScore, + resolvedBy: 'framework', + }; + } + + return null; + }, + + // ------------------------------------------------------------------ + // extract + // ------------------------------------------------------------------ + extract(filePath, content) { + if (!filePath.endsWith('.ets')) return { nodes: [], references: [] }; + + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + + // ArkTS is a TypeScript superset — identical comment/string syntax. + const safe = stripCommentsForRegex(content, 'typescript'); + + // ── Pass 1: @Entry-decorated structs → arkui_page nodes ─────── + // + // Pattern: @Entry (optionally followed by @Component / @Preview / + // @V2 / @Observed / @Reusable, possibly with params like + // @Entry({ routeName: 'main' })) then `struct Name`. + // (?:[^{}]|\{[^}]*\})*? skips decorator param braces but stops + // before the struct body's opening brace. + const entryRe = /@Entry\b(?:[^{}]|\{[^}]*\})*?struct\s+(\w+)/g; + const entryPositions: Array<{ line: number; name: string; node: Node }> = []; + + let match: RegExpExecArray | null; + while ((match = entryRe.exec(safe)) !== null) { + const structName = match[1]!; + const line = safe.slice(0, match.index).split('\n').length; + const pageNode: Node = { + id: `arkui_page:${filePath}:${line}:${structName}`, + kind: 'arkui_page', + name: structName, + qualifiedName: `${filePath}::${structName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'arkts', + updatedAt: now, + }; + nodes.push(pageNode); + entryPositions.push({ line, name: structName, node: pageNode }); + + // Link the arkui_page node back to its declaring struct via a + // references edge. Standard name-based resolution matches this + // to the struct node produced by tree-sitter extraction. + references.push({ + fromNodeId: pageNode.id, + referenceName: structName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'arkts', + }); + } + + // ── Pass 2: router.pushUrl / router.replaceUrl → references ──── + // + // Pattern: router.pushUrl({ ..., url: 'pages/X', ... }) + // Uses [\s\S]*? to span multiple lines inside the object literal. + const pushUrlRe = + /router\.(pushUrl|replaceUrl)\s*\(\s*\{[\s\S]*?url\s*:\s*['"]([^'"]+)['"]/g; + pushUrlRe.lastIndex = 0; + + while ((match = pushUrlRe.exec(safe)) !== null) { + const url = match[2]!; + const callLine = safe.slice(0, match.index).split('\n').length; + + // Attribute the navigation call to the nearest preceding @Entry + // struct — the page that contains this router.pushUrl call. + let fromNodeId = `file:${filePath}`; + for (let i = entryPositions.length - 1; i >= 0; i--) { + const entry = entryPositions[i]!; + if (entry.line < callLine) { + fromNodeId = entry.node.id; + break; + } + } + + references.push({ + fromNodeId, + referenceName: url, + referenceKind: 'references', + line: callLine, + column: 0, + filePath, + language: 'arkts', + }); + } + + // ── Pass 3: @Component-decorated structs (without @Entry) → component nodes + // + // Pattern: @Component (optionally followed by @Preview / @V2 / + // @Observed / @Reusable, possibly with params) then `struct Name`, + // excluding structs already captured as @Entry pages. + const entryNames = new Set(entryPositions.map((e) => e.name)); + const componentRe = /@Component\b(?:[^{}]|\{[^}]*\})*?struct\s+(\w+)/g; + componentRe.lastIndex = 0; + + while ((match = componentRe.exec(safe)) !== null) { + const structName = match[1]!; + if (entryNames.has(structName)) continue; + + const line = safe.slice(0, match.index).split('\n').length; + const componentNode: Node = { + id: `component:${filePath}:${line}:${structName}`, + kind: 'component', + name: structName, + qualifiedName: `${filePath}::${structName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'arkts', + updatedAt: now, + decorators: ['Component'], + }; + nodes.push(componentNode); + + // Link the component node back to its declaring struct via a + // references edge — same pattern as arkui_page nodes. + references.push({ + fromNodeId: componentNode.id, + referenceName: structName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'arkts', + }); + } + + return { nodes, references }; + }, + + // ------------------------------------------------------------------ + // postExtract + // ------------------------------------------------------------------ + postExtract(context: ResolutionContext): Node[] { + // main_pages.json (HarmonyOS 5.0+) lists all page entry routes. + // Common locations: src/main/resources/base/profile/main_pages.json + // or main_pages.json at project root. + const content = + context.readFile( + 'entry/src/main/resources/base/profile/main_pages.json' + ) ?? context.readFile('main_pages.json'); + if (!content) return []; + + let config: { src?: string[] }; + try { + config = JSON.parse(content); + } catch { + return []; + } + + const pages: string[] = config.src ?? []; + if (pages.length === 0) return []; + + // Only emit nodes for pages not already captured by extract(). + const existingRoutes = context.getNodesByKind('arkui_page'); + const now = Date.now(); + const nodes: Node[] = []; + + for (const pagePath of pages) { + const alreadyExists = existingRoutes.some( + (n) => + n.filePath.endsWith(pagePath + '.ets') || + n.filePath.endsWith(pagePath + '/index.ets') + ); + if (alreadyExists) continue; + + nodes.push({ + id: `arkui_page:main_pages.json:0:${pagePath}`, + kind: 'arkui_page', + name: pagePath, + qualifiedName: `main_pages.json::${pagePath}`, + filePath: 'main_pages.json', + startLine: 0, + endLine: 0, + startColumn: 0, + endColumn: 0, + language: 'arkts', + updatedAt: now, + }); + } + + return nodes; + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 88bf205e6..6bd985307 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -25,6 +25,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { arkuiResolver } from './arkui'; /** * All registered framework resolvers @@ -66,6 +67,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // ArkUI (HarmonyOS) + arkuiResolver, ]; /** @@ -140,3 +143,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { arkuiResolver } from './arkui'; diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 0c1f16055..e1ca0b683 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -325,6 +325,7 @@ export function kindBonus(kind: Node['kind']): number { enum: 5, component: 8, route: 9, + arkui_page: 9, module: 4, property: 3, field: 3, diff --git a/src/types.ts b/src/types.ts index e710e31a1..6cb2bcff9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export const NODE_KINDS = [ 'export', 'route', 'component', + 'arkui_page', ] as const; export type NodeKind = (typeof NODE_KINDS)[number]; @@ -88,6 +89,7 @@ export const LANGUAGES = [ 'lua', 'luau', 'objc', + 'arkts', 'yaml', 'twig', 'xml',