loads of UI improvements, also improves the console UI

This commit is contained in:
2026-09-03 09:14:30 +02:00
parent 37d15a29f0
commit 03e1f90515
16 changed files with 1108 additions and 81 deletions

108
test/symbols.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { isImportLine, parseDeclaration, parseNamespace, parseTraitUse } from '../src/main/symbols-service'
import { wordAt } from '../src/renderer/src/editor'
describe('parseDeclaration', () => {
it('reads every declaration keyword', () => {
expect(parseDeclaration('class Agent')).toMatchObject({ kind: 'class', name: 'Agent' })
expect(parseDeclaration('interface Searchable {')).toMatchObject({ kind: 'interface', name: 'Searchable' })
expect(parseDeclaration('trait HasVault')).toMatchObject({ kind: 'trait', name: 'HasVault' })
expect(parseDeclaration('enum Status: string')).toMatchObject({ kind: 'enum', name: 'Status' })
})
it('reads the parents off the declaration line, however many', () => {
const d = parseDeclaration('final class VaultSearch extends Base implements Searchable, Arrayable, Jsonable {')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Searchable', 'Arrayable', 'Jsonable'])
})
it('drops the namespace of a fully qualified parent', () => {
const d = parseDeclaration('class A extends \\App\\Base implements \\App\\Contracts\\Runs')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Runs'])
})
it('reads an interface extending several interfaces, and an enum contract', () => {
expect(parseDeclaration('interface A extends B, C')?.parents).toEqual(['B', 'C'])
expect(parseDeclaration('enum Status: string implements HasLabel')?.interfaces).toEqual(['HasLabel'])
})
it('leaves the lists empty when there is no clause', () => {
const d = parseDeclaration('class Plain {')
expect(d?.parents).toEqual([])
expect(d?.interfaces).toEqual([])
})
it('accepts the modifiers PHP allows in front', () => {
expect(parseDeclaration('final class A extends B')?.name).toBe('A')
expect(parseDeclaration('abstract class A')?.name).toBe('A')
expect(parseDeclaration('final readonly class A')?.name).toBe('A')
expect(parseDeclaration(' class Nested')?.name).toBe('Nested')
})
it('ignores a mention that is not a declaration', () => {
expect(parseDeclaration('$x = Agent::class;')).toBeNull()
expect(parseDeclaration('// class Agent lives here')).toBeNull()
expect(parseDeclaration('use App\\Models\\Agent;')).toBeNull()
expect(parseDeclaration('return $a instanceof Agent;')).toBeNull()
})
})
describe('isImportLine', () => {
it('drops a namespace import', () => {
expect(isImportLine('use App\\Models\\Agent;')).toBe(true)
expect(isImportLine('use App\\Support\\Str as S;')).toBe(true)
expect(isImportLine('use function App\\Support\\tap;')).toBe(true)
})
it('keeps a trait mixed into a class body', () => {
// The indent is the whole difference: a trait `use` is inside the class.
expect(isImportLine(' use HasFactory;')).toBe(false)
expect(isImportLine('\tuse HasFactory;')).toBe(false)
})
it('keeps real code that merely contains the word', () => {
expect(isImportLine('$f = function () use ($agent) {};')).toBe(false)
expect(isImportLine('// use Agent for this')).toBe(false)
})
})
describe('parseNamespace', () => {
it('reads the namespace of a file', () => {
expect(parseNamespace('namespace App\\Console\\Commands;')).toBe('App\\Console\\Commands')
expect(parseNamespace('namespace App;')).toBe('App')
})
it('returns null for anything else', () => {
expect(parseNamespace('use App\\Models\\Agent;')).toBeNull()
expect(parseNamespace('class A {')).toBeNull()
})
})
describe('parseTraitUse', () => {
it('reads one or several traits from an indented use', () => {
expect(parseTraitUse(' use HasFactory;')).toEqual(['HasFactory'])
expect(parseTraitUse(' use HasFactory, Notifiable, SoftDeletes;')).toEqual(['HasFactory', 'Notifiable', 'SoftDeletes'])
expect(parseTraitUse('\tuse App\\Support\\HasVault;')).toEqual(['HasVault'])
})
it('reads a use with a conflict-resolution block', () => {
expect(parseTraitUse(' use A, B { A::run insteadof B; }')).toEqual(['A', 'B'])
})
it('ignores what is not a trait', () => {
expect(parseTraitUse('use App\\Models\\Agent;')).toEqual([]) // import, not indented
expect(parseTraitUse(' use function App\\tap;')).toEqual([])
expect(parseTraitUse(' $f = function () use ($x) {};')).toEqual([])
})
})
describe('wordAt', () => {
const line = 'return new Agent($x);'
it('reads the identifier under the caret', () => {
expect(wordAt(line, 12)).toBe('Agent') // inside
expect(wordAt(line, 11)).toBe('Agent') // left edge
expect(wordAt(line, 16)).toBe('Agent') // right edge
})
it('returns empty off an identifier', () => {
expect(wordAt(line, 17)).toBe('') // inside "($x"
expect(wordAt(' ', 1)).toBe('')
})
it('rejects a word that cannot be a class name', () => {
expect(wordAt('$x = 42;', 6)).toBe('') // digits only
})
it('handles a caret at the string edges', () => {
expect(wordAt('Agent', 0)).toBe('Agent')
expect(wordAt('Agent', 5)).toBe('Agent')
expect(wordAt('Agent', 9)).toBe('')
})
})