@dot-agent/language-server 0.4.1 → 0.5.0-alpha.1
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/AGENTS.md +27 -8
- package/features/completions.js +3 -7
- package/features/definition.js +9 -13
- package/features/diagnostics.js +83 -179
- package/features/formatting.js +1 -5
- package/features/hover.js +2 -6
- package/features/links.js +6 -10
- package/features/references.js +2 -6
- package/features/rename.js +2 -6
- package/features/symbols.js +2 -6
- package/merge-graph.js +129 -0
- package/package.json +32 -8
- package/parser.js +23 -29
- package/server.js +45 -28
- package/tests/diagnostics.test.js +209 -0
- package/tests/formatting.test.js +87 -0
- package/tests/hover.test.js +87 -0
- package/tests/parser.test.js +215 -0
- package/tests/references.test.js +52 -0
- package/tests/rename.test.js +47 -0
- package/tests/symbols.test.js +68 -0
- package/.github/workflows/publish.yml +0 -28
- package/features/graph.js +0 -53
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { format } from '../features/formatting.js'
|
|
11
|
+
|
|
12
|
+
// ── behavior formatting ───────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('format — behavior', () => {
|
|
15
|
+
it('returns no edits for already-correct indentation', () => {
|
|
16
|
+
const text = [
|
|
17
|
+
'state init',
|
|
18
|
+
' goal "hello"',
|
|
19
|
+
' interact',
|
|
20
|
+
' on intent "go" transition to next',
|
|
21
|
+
' on offtopic transition to init',
|
|
22
|
+
'',
|
|
23
|
+
'state next',
|
|
24
|
+
' guide "done"',
|
|
25
|
+
].join('\n')
|
|
26
|
+
expect(format('behavior', text)).toHaveLength(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('fixes over-indented state declaration', () => {
|
|
30
|
+
const text = ' state init\n goal "hi"'
|
|
31
|
+
const edits = format('behavior', text)
|
|
32
|
+
const stateFix = edits.find(e => e.range.start.line === 0)
|
|
33
|
+
expect(stateFix).toBeDefined()
|
|
34
|
+
expect(stateFix.newText).toBe('')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('fixes missing indentation inside state body', () => {
|
|
38
|
+
const text = 'state init\nguide "hello"'
|
|
39
|
+
const edits = format('behavior', text)
|
|
40
|
+
const guideFix = edits.find(e => e.range.start.line === 1)
|
|
41
|
+
expect(guideFix).toBeDefined()
|
|
42
|
+
expect(guideFix.newText).toBe(' ')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('produces no edits for empty text', () => {
|
|
46
|
+
expect(format('behavior', '')).toHaveLength(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('produces no edits for blank lines', () => {
|
|
50
|
+
expect(format('behavior', '\n\n\n')).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ── description formatting ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe('format — description', () => {
|
|
57
|
+
it('returns no edits for already-correct indentation', () => {
|
|
58
|
+
const text = 'agent Doctor\n domain example.com\n license MIT'
|
|
59
|
+
expect(format('description', text)).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('fixes wrong indent on line starting with space (3→2)', () => {
|
|
63
|
+
// 3 spaces → starts with space → expectedIndent=2 → fix to 2
|
|
64
|
+
const text = ' agent Doctor\n domain example.com'
|
|
65
|
+
const edits = format('description', text)
|
|
66
|
+
const fix = edits.find(e => e.range.start.line === 0)
|
|
67
|
+
expect(fix).toBeDefined()
|
|
68
|
+
expect(fix.newText).toBe(' ')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('fixes wrong indent on nested property (4→2)', () => {
|
|
72
|
+
// 4 spaces → starts with space → expectedIndent=2 → fix to 2
|
|
73
|
+
const text = 'agent Doctor\n domain example.com'
|
|
74
|
+
const edits = format('description', text)
|
|
75
|
+
const fix = edits.find(e => e.range.start.line === 1)
|
|
76
|
+
expect(fix).toBeDefined()
|
|
77
|
+
expect(fix.newText).toBe(' ')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ── unknown langId ────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('format — unknown langId', () => {
|
|
84
|
+
it('returns empty array', () => {
|
|
85
|
+
expect(format('unknown', 'anything')).toHaveLength(0)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { provideHover } from '../features/hover.js'
|
|
11
|
+
|
|
12
|
+
// ── behavior keywords ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('provideHover — behavior', () => {
|
|
15
|
+
it('returns docs for "state" keyword', () => {
|
|
16
|
+
const result = provideHover('behavior', 'state init', { line: 0, character: 2 })
|
|
17
|
+
expect(result).not.toBeNull()
|
|
18
|
+
expect(result.contents.value).toMatch(/state/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns docs for "goal" keyword', () => {
|
|
22
|
+
const result = provideHover('behavior', ' goal "help"', { line: 0, character: 4 })
|
|
23
|
+
expect(result).not.toBeNull()
|
|
24
|
+
expect(result.contents.value).toMatch(/goal/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns docs for "interact" keyword', () => {
|
|
28
|
+
const result = provideHover('behavior', ' interact', { line: 0, character: 4 })
|
|
29
|
+
expect(result).not.toBeNull()
|
|
30
|
+
expect(result.contents.value).toMatch(/interact/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns docs for "transition" keyword', () => {
|
|
34
|
+
const result = provideHover('behavior', ' transition to next', { line: 0, character: 5 })
|
|
35
|
+
expect(result).not.toBeNull()
|
|
36
|
+
expect(result.contents.value).toMatch(/transition/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns docs for "merge" keyword', () => {
|
|
40
|
+
const result = provideHover('behavior', 'merge "base.behavior"', { line: 0, character: 2 })
|
|
41
|
+
expect(result).not.toBeNull()
|
|
42
|
+
expect(result.contents.value).toMatch(/merge/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns docs for "teach" keyword', () => {
|
|
46
|
+
const result = provideHover('behavior', ' teach "file.md"', { line: 0, character: 3 })
|
|
47
|
+
expect(result).not.toBeNull()
|
|
48
|
+
expect(result.contents.value).toMatch(/teach/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns null for unknown word', () => {
|
|
52
|
+
const result = provideHover('behavior', ' unknownkeyword', { line: 0, character: 5 })
|
|
53
|
+
expect(result).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null for empty position', () => {
|
|
57
|
+
const result = provideHover('behavior', ' ', { line: 0, character: 0 })
|
|
58
|
+
expect(result).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ── description keywords ──────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe('provideHover — description', () => {
|
|
65
|
+
it('returns docs for "agent" keyword', () => {
|
|
66
|
+
const result = provideHover('description', 'agent Doctor', { line: 0, character: 2 })
|
|
67
|
+
expect(result).not.toBeNull()
|
|
68
|
+
expect(result.contents.value).toMatch(/agent/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns docs for "domain" keyword', () => {
|
|
72
|
+
const result = provideHover('description', ' domain example.com', { line: 0, character: 4 })
|
|
73
|
+
expect(result).not.toBeNull()
|
|
74
|
+
expect(result.contents.value).toMatch(/domain/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns docs for "type" keyword', () => {
|
|
78
|
+
const result = provideHover('description', 'type Patient', { line: 0, character: 2 })
|
|
79
|
+
expect(result).not.toBeNull()
|
|
80
|
+
expect(result.contents.value).toMatch(/type/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns null for behavior-only keyword in description mode', () => {
|
|
84
|
+
const result = provideHover('description', 'state init', { line: 0, character: 2 })
|
|
85
|
+
expect(result).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
initParsers,
|
|
12
|
+
parse,
|
|
13
|
+
evict,
|
|
14
|
+
nodesOfType,
|
|
15
|
+
nodeAtOffset,
|
|
16
|
+
nodeToRange,
|
|
17
|
+
positionToOffset,
|
|
18
|
+
wordAtPosition,
|
|
19
|
+
getContextNode,
|
|
20
|
+
} from '../parser.js'
|
|
21
|
+
|
|
22
|
+
// transition-only state bodies are the safest valid fixture
|
|
23
|
+
const BEHAVIOR_TEXT = `\
|
|
24
|
+
state init
|
|
25
|
+
transition to next
|
|
26
|
+
|
|
27
|
+
state next
|
|
28
|
+
transition to init
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
const DESCRIPTION_TEXT = `\
|
|
32
|
+
agent Doctor
|
|
33
|
+
domain health.example.com
|
|
34
|
+
license MIT
|
|
35
|
+
|
|
36
|
+
description
|
|
37
|
+
Clinical diagnostic agent.
|
|
38
|
+
|
|
39
|
+
input Patient
|
|
40
|
+
output Prescription
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await initParsers()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ── parse + cache ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('parse', () => {
|
|
50
|
+
it('parses behavior text and returns a tree', () => {
|
|
51
|
+
const tree = parse('uri:test.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
52
|
+
expect(tree).not.toBeNull()
|
|
53
|
+
expect(tree.rootNode.type).toBe('behavior_file')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns the cached tree for the same version', () => {
|
|
57
|
+
const t1 = parse('uri:cached.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
58
|
+
const t2 = parse('uri:cached.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
59
|
+
expect(t1).toBe(t2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('reparses when version changes', () => {
|
|
63
|
+
const t1 = parse('uri:versioned.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
64
|
+
const t2 = parse('uri:versioned.behavior', 'behavior', BEHAVIOR_TEXT + '\n', 2)
|
|
65
|
+
expect(t1).not.toBe(t2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('parses description text', () => {
|
|
69
|
+
const tree = parse('uri:test.description', 'description', DESCRIPTION_TEXT, 1)
|
|
70
|
+
expect(tree).not.toBeNull()
|
|
71
|
+
expect(tree.rootNode.type).toBe('manifest')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns no parse errors for valid behavior', () => {
|
|
75
|
+
const tree = parse('uri:valid.behavior', 'behavior', BEHAVIOR_TEXT, 2)
|
|
76
|
+
expect(tree.rootNode.hasError).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Regression: parse() used to hand the previous version's tree to
|
|
80
|
+
// tree-sitter as an incremental-reuse hint without ever calling
|
|
81
|
+
// tree.edit() first, which tree-sitter requires to keep node byte ranges
|
|
82
|
+
// valid. Without it, node.text silently returns garbled/truncated
|
|
83
|
+
// strings after an edit shifts content — surfaced as "name must not be
|
|
84
|
+
// falsy" when the language server converted a corrupted state name to a
|
|
85
|
+
// DocumentSymbol.
|
|
86
|
+
it('does not corrupt node text across an edit at the same uri', () => {
|
|
87
|
+
const uri = 'uri:edited.behavior'
|
|
88
|
+
parse(uri, 'behavior', BEHAVIOR_TEXT, 1)
|
|
89
|
+
const editedText = 'state prepended\n transition to init\n\n' + BEHAVIOR_TEXT
|
|
90
|
+
const tree = parse(uri, 'behavior', editedText, 2)
|
|
91
|
+
const names = nodesOfType(tree, 'state_decl').map(n => n.childForFieldName('name')?.text)
|
|
92
|
+
expect(names).toEqual(['prepended', 'init', 'next'])
|
|
93
|
+
expect(names.every(n => n && n.length > 0)).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ── evict ─────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('evict', () => {
|
|
100
|
+
it('removes cached tree so next parse re-builds', () => {
|
|
101
|
+
const uri = 'uri:evict-test.behavior'
|
|
102
|
+
const t1 = parse(uri, 'behavior', BEHAVIOR_TEXT, 1)
|
|
103
|
+
evict(uri)
|
|
104
|
+
const t2 = parse(uri, 'behavior', BEHAVIOR_TEXT, 1)
|
|
105
|
+
// After evict, same version → new tree object
|
|
106
|
+
expect(t1).not.toBe(t2)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ── nodesOfType ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('nodesOfType', () => {
|
|
113
|
+
it('returns all state_decl nodes from a behavior tree', () => {
|
|
114
|
+
const tree = parse('uri:nodes.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
115
|
+
const states = nodesOfType(tree, 'state_decl')
|
|
116
|
+
expect(states.length).toBe(2)
|
|
117
|
+
expect(states.map(n => n.childForFieldName('name')?.text)).toEqual(['init', 'next'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('returns empty array for unknown node type', () => {
|
|
121
|
+
const tree = parse('uri:nodes2.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
122
|
+
expect(nodesOfType(tree, 'nonexistent_node')).toHaveLength(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('returns empty array for null tree', () => {
|
|
126
|
+
expect(nodesOfType(null, 'state_decl')).toHaveLength(0)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns agent_decl from description tree', () => {
|
|
130
|
+
const tree = parse('uri:desc-nodes.description', 'description', DESCRIPTION_TEXT, 1)
|
|
131
|
+
const agents = nodesOfType(tree, 'agent_decl')
|
|
132
|
+
expect(agents.length).toBe(1)
|
|
133
|
+
expect(agents[0].childForFieldName('name')?.text).toBe('Doctor')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── nodeToRange ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe('nodeToRange', () => {
|
|
140
|
+
it('converts a node to an LSP range', () => {
|
|
141
|
+
const tree = parse('uri:range.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
142
|
+
const [firstState] = nodesOfType(tree, 'state_decl')
|
|
143
|
+
const range = nodeToRange(firstState)
|
|
144
|
+
expect(range.start.line).toBe(0)
|
|
145
|
+
expect(range.start.character).toBe(0)
|
|
146
|
+
expect(range.end.line).toBeGreaterThanOrEqual(1)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ── positionToOffset ──────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('positionToOffset', () => {
|
|
153
|
+
it('returns 0 for line 0 character 0', () => {
|
|
154
|
+
expect(positionToOffset('hello\nworld', 0, 0)).toBe(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns correct offset for line 1', () => {
|
|
158
|
+
expect(positionToOffset('hello\nworld', 1, 0)).toBe(6)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('returns correct offset for mid-line character', () => {
|
|
162
|
+
expect(positionToOffset('hello\nworld', 0, 3)).toBe(3)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ── wordAtPosition ────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('wordAtPosition', () => {
|
|
169
|
+
it('extracts identifier at cursor inside word', () => {
|
|
170
|
+
const { word } = wordAtPosition(' transition to init', 0, 5)
|
|
171
|
+
expect(word).toBe('transition')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('extracts dotted identifier', () => {
|
|
175
|
+
const { word } = wordAtPosition('set context.name', 0, 6)
|
|
176
|
+
expect(word).toBe('context.name')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('extracts word when cursor is at a boundary between word and space', () => {
|
|
180
|
+
// At "state init", ch=5 (the space) — walks back into "state"
|
|
181
|
+
const { word } = wordAtPosition('state init', 0, 5)
|
|
182
|
+
expect(word).toBe('state')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('returns empty string when cursor is before any word character', () => {
|
|
186
|
+
const { word } = wordAtPosition(' state init', 0, 0)
|
|
187
|
+
expect(word).toBe('')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ── nodeAtOffset ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe('nodeAtOffset', () => {
|
|
194
|
+
it('returns a node at the given byte offset', () => {
|
|
195
|
+
const tree = parse('uri:offset.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
196
|
+
const node = nodeAtOffset(tree, 0)
|
|
197
|
+
expect(node).not.toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('returns null for null tree', () => {
|
|
201
|
+
expect(nodeAtOffset(null, 0)).toBeNull()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// ── getContextNode ────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('getContextNode', () => {
|
|
208
|
+
it('returns a non-error node for valid position', () => {
|
|
209
|
+
const tree = parse('uri:ctx.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
210
|
+
const offset = positionToOffset(BEHAVIOR_TEXT, 0, 6)
|
|
211
|
+
const node = getContextNode(tree, offset)
|
|
212
|
+
expect(node).not.toBeNull()
|
|
213
|
+
expect(node.isError).toBeFalsy()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
10
|
+
import { initParsers, parse } from '../parser.js'
|
|
11
|
+
import { provideReferences } from '../features/references.js'
|
|
12
|
+
|
|
13
|
+
const BEHAVIOR_TEXT = `\
|
|
14
|
+
state init
|
|
15
|
+
transition to next
|
|
16
|
+
transition to next
|
|
17
|
+
|
|
18
|
+
state next
|
|
19
|
+
transition to init
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => { await initParsers() })
|
|
23
|
+
|
|
24
|
+
describe('provideReferences — behavior states', () => {
|
|
25
|
+
const uri = 'file:///test.behavior'
|
|
26
|
+
|
|
27
|
+
it('finds declaration + all transition targets for "next"', () => {
|
|
28
|
+
const tree = parse(uri, 'behavior', BEHAVIOR_TEXT, 1)
|
|
29
|
+
// "next" is at line 4, char 6
|
|
30
|
+
const refs = provideReferences('behavior', tree, BEHAVIOR_TEXT, uri, { line: 4, character: 6 })
|
|
31
|
+
// 1 declaration + 2 transition targets
|
|
32
|
+
expect(refs.length).toBe(3)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('finds declaration + one transition for "init"', () => {
|
|
36
|
+
const tree = parse(uri + '2', 'behavior', BEHAVIOR_TEXT, 1)
|
|
37
|
+
// "init" is at line 0, char 6
|
|
38
|
+
const refs = provideReferences('behavior', tree, BEHAVIOR_TEXT, uri + '2', { line: 0, character: 6 })
|
|
39
|
+
// 1 declaration + 1 transition target
|
|
40
|
+
expect(refs.length).toBe(2)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns empty array for null tree', () => {
|
|
44
|
+
expect(provideReferences('behavior', null, '', uri, { line: 0, character: 0 })).toHaveLength(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns empty array when word not found', () => {
|
|
48
|
+
const tree = parse(uri + '3', 'behavior', BEHAVIOR_TEXT, 1)
|
|
49
|
+
const refs = provideReferences('behavior', tree, BEHAVIOR_TEXT, uri + '3', { line: 1, character: 0 })
|
|
50
|
+
expect(refs).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
10
|
+
import { initParsers, parse } from '../parser.js'
|
|
11
|
+
import { provideRenameEdits } from '../features/rename.js'
|
|
12
|
+
|
|
13
|
+
const BEHAVIOR_TEXT = `\
|
|
14
|
+
state init
|
|
15
|
+
transition to next
|
|
16
|
+
|
|
17
|
+
state next
|
|
18
|
+
transition to init
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => { await initParsers() })
|
|
22
|
+
|
|
23
|
+
describe('provideRenameEdits — behavior', () => {
|
|
24
|
+
const uri = 'file:///test.behavior'
|
|
25
|
+
|
|
26
|
+
it('renames state declaration and all transition targets', () => {
|
|
27
|
+
const tree = parse(uri, 'behavior', BEHAVIOR_TEXT, 1)
|
|
28
|
+
// "next" is at line 3, char 6
|
|
29
|
+
const result = provideRenameEdits('behavior', tree, BEHAVIOR_TEXT, uri, { line: 3, character: 6 }, 'renamed')
|
|
30
|
+
expect(result).not.toBeNull()
|
|
31
|
+
const edits = result.changes[uri]
|
|
32
|
+
// 1 declaration + 1 transition target
|
|
33
|
+
expect(edits).toHaveLength(2)
|
|
34
|
+
expect(edits.every(e => e.newText === 'renamed')).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns null when word is not a declared state', () => {
|
|
38
|
+
const tree = parse(uri + '2', 'behavior', BEHAVIOR_TEXT, 1)
|
|
39
|
+
// cursor on "transition" keyword — not a state name
|
|
40
|
+
const result = provideRenameEdits('behavior', tree, BEHAVIOR_TEXT, uri + '2', { line: 1, character: 2 }, 'x')
|
|
41
|
+
expect(result).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns null for null tree', () => {
|
|
45
|
+
expect(provideRenameEdits('behavior', null, '', uri, { line: 0, character: 0 }, 'x')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
10
|
+
import { initParsers, parse } from '../parser.js'
|
|
11
|
+
import { provideDocumentSymbols } from '../features/symbols.js'
|
|
12
|
+
|
|
13
|
+
const BEHAVIOR_TEXT = `\
|
|
14
|
+
on event "start"
|
|
15
|
+
transition to init
|
|
16
|
+
|
|
17
|
+
state init
|
|
18
|
+
transition to next
|
|
19
|
+
|
|
20
|
+
state next
|
|
21
|
+
transition to init
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const DESCRIPTION_TEXT = `\
|
|
25
|
+
agent Doctor
|
|
26
|
+
domain health.example.com
|
|
27
|
+
license MIT
|
|
28
|
+
|
|
29
|
+
description
|
|
30
|
+
Clinical diagnostic agent.
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => { await initParsers() })
|
|
34
|
+
|
|
35
|
+
describe('provideDocumentSymbols — behavior', () => {
|
|
36
|
+
it('returns all state_decl as Class symbols', () => {
|
|
37
|
+
const tree = parse('uri:sym.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
38
|
+
const symbols = provideDocumentSymbols('behavior', tree)
|
|
39
|
+
const states = symbols.filter(s => s.kind === 5) // Class
|
|
40
|
+
expect(states.map(s => s.name)).toEqual(['init', 'next'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns trigger_decl as Event symbol', () => {
|
|
44
|
+
const tree = parse('uri:sym2.behavior', 'behavior', BEHAVIOR_TEXT, 1)
|
|
45
|
+
const symbols = provideDocumentSymbols('behavior', tree)
|
|
46
|
+
const events = symbols.filter(s => s.kind === 24) // Event
|
|
47
|
+
expect(events).toHaveLength(1)
|
|
48
|
+
expect(events[0].name).toMatch(/start/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns empty array for null tree', () => {
|
|
52
|
+
expect(provideDocumentSymbols('behavior', null)).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('provideDocumentSymbols — description', () => {
|
|
57
|
+
it('returns agent_decl as Class symbol', () => {
|
|
58
|
+
const tree = parse('uri:sym.description', 'description', DESCRIPTION_TEXT, 1)
|
|
59
|
+
const symbols = provideDocumentSymbols('description', tree)
|
|
60
|
+
const agents = symbols.filter(s => s.kind === 5)
|
|
61
|
+
expect(agents).toHaveLength(1)
|
|
62
|
+
expect(agents[0].name).toBe('Doctor')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns empty array for null tree', () => {
|
|
66
|
+
expect(provideDocumentSymbols('description', null)).toHaveLength(0)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
id-token: write
|
|
9
|
-
contents: read
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
publish:
|
|
13
|
-
runs-on: ubuntu-latest
|
|
14
|
-
steps:
|
|
15
|
-
- name: Checkout repository
|
|
16
|
-
uses: actions/checkout@v4
|
|
17
|
-
|
|
18
|
-
- name: Setup Node.js
|
|
19
|
-
uses: actions/setup-node@v4
|
|
20
|
-
with:
|
|
21
|
-
node-version: '24.x'
|
|
22
|
-
registry-url: 'https://registry.npmjs.org'
|
|
23
|
-
|
|
24
|
-
- name: Install dependencies
|
|
25
|
-
run: npm ci
|
|
26
|
-
|
|
27
|
-
- name: Publish package to npm
|
|
28
|
-
run: npm publish
|
package/features/graph.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
'use strict';
|
|
18
|
-
|
|
19
|
-
const { nodesOfType } = require('../parser');
|
|
20
|
-
|
|
21
|
-
function extractBehaviorGraph(tree) {
|
|
22
|
-
const states = nodesOfType(tree, 'state_decl')
|
|
23
|
-
.map(n => n.childForFieldName('name')?.text)
|
|
24
|
-
.filter(Boolean);
|
|
25
|
-
|
|
26
|
-
const transitions = [];
|
|
27
|
-
const seen = new Set();
|
|
28
|
-
for (const stateNode of nodesOfType(tree, 'state_decl')) {
|
|
29
|
-
const from = stateNode.childForFieldName('name')?.text;
|
|
30
|
-
if (!from) continue;
|
|
31
|
-
for (const t of stateNode.descendantsOfType('transition_stmt')) {
|
|
32
|
-
const to = t.childForFieldName('state')?.text;
|
|
33
|
-
if (to) {
|
|
34
|
-
const key = `${from}→${to}`;
|
|
35
|
-
if (!seen.has(key)) { seen.add(key); transitions.push({ from, to }); }
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const entryPoints = [];
|
|
41
|
-
for (const triggerNode of nodesOfType(tree, 'trigger_decl')) {
|
|
42
|
-
// event field is a quoted_string WITHOUT surrounding quotes (quotes are literals in the grammar rule)
|
|
43
|
-
const eventText = triggerNode.childForFieldName('event')?.text;
|
|
44
|
-
for (const t of triggerNode.descendantsOfType('transition_stmt')) {
|
|
45
|
-
const to = t.childForFieldName('state')?.text;
|
|
46
|
-
if (eventText && to) entryPoints.push({ event: eventText, to });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return { states, transitions, entryPoints };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
module.exports = { extractBehaviorGraph };
|