render.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import { describe, expect, it } from 'vitest'
  2. import { pathToFileURL } from 'node:url'
  3. import { join, resolve } from 'node:path'
  4. import {
  5. DEFAULT_MAX_LOCATIONS,
  6. DEFAULT_MAX_RESULT_CHARS,
  7. formatHover,
  8. formatLocations,
  9. LSP_OPERATIONS,
  10. parseLspArgs,
  11. presentLspCall,
  12. renderUri,
  13. } from '@deepseek-ai/dsh-tool-lsp'
  14. import type { LspLocation } from '@deepseek-ai/dsh-lsp'
  15. const WS = resolve('/home/u/proj')
  16. function loc(uri: string, line: number, character = 0): LspLocation {
  17. return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
  18. }
  19. describe('parseLspArgs', () => {
  20. it('accepts the four operations and converts one-based to zero-based', () => {
  21. for (const operation of LSP_OPERATIONS) {
  22. const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
  23. expect(input.operation).toBe(operation)
  24. expect(input.position).toEqual({ line: 2, character: 4 })
  25. }
  26. })
  27. it('rejects an unknown operation', () => {
  28. expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
  29. .toThrow(/operation must be one of/)
  30. })
  31. it('rejects a blank file_path', () => {
  32. expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 }))
  33. .toThrow(/file_path/)
  34. })
  35. it('rejects non-positive or non-integer coordinates', () => {
  36. expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
  37. expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
  38. expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
  39. })
  40. })
  41. describe('renderUri', () => {
  42. it('relativizes a file: URI inside the workspace with forward slashes', () => {
  43. const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
  44. expect(renderUri(uri, WS)).toBe('src/a.ts')
  45. })
  46. it('returns an absolute path for a file: URI outside the workspace', () => {
  47. const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
  48. const uri = pathToFileURL(outside).href
  49. expect(renderUri(uri, WS)).toBe(outside)
  50. })
  51. it('renders the workspace root itself as "."', () => {
  52. expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
  53. })
  54. it('keeps an in-workspace path whose first segment starts with dots relative', () => {
  55. // `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
  56. const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
  57. expect(renderUri(uri, WS)).toBe('..generated/a.ts')
  58. })
  59. it('keeps a non-file URI verbatim', () => {
  60. expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
  61. expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
  62. })
  63. it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
  64. // An encoded path separator is invalid on every platform and must remain verbatim.
  65. expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
  66. })
  67. })
  68. describe('formatLocations', () => {
  69. it('renders a no-result line for an empty list', () => {
  70. expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
  71. })
  72. it('renders one-based path:line:character grouped by file', () => {
  73. const a = pathToFileURL(join(WS, 'a.ts')).href
  74. const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
  75. expect(text).toBe('a.ts:1:1\na.ts:5:3')
  76. })
  77. it('caps at maxLocations and marks the omission', () => {
  78. const a = pathToFileURL(join(WS, 'a.ts')).href
  79. const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
  80. const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
  81. expect(text).toContain('a.ts:1:1')
  82. expect(text).toContain('3 more locations omitted (limit 2).')
  83. })
  84. it('uses the singular omission marker for exactly one extra', () => {
  85. const a = pathToFileURL(join(WS, 'a.ts')).href
  86. const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
  87. expect(text).toContain('1 more location omitted (limit 1).')
  88. })
  89. it('caps the complete location text even when one URI is enormous', () => {
  90. const maxResultChars = 80
  91. const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
  92. expect(text).toHaveLength(maxResultChars)
  93. expect(text).toContain('locations truncated')
  94. })
  95. })
  96. describe('formatHover', () => {
  97. it('renders a no-result line for null', () => {
  98. expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.')
  99. })
  100. it('returns short hover verbatim', () => {
  101. expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```')
  102. })
  103. it('caps the complete hover text including its truncation marker', () => {
  104. const text = formatHover({ contents: 'a'.repeat(100) }, 60)
  105. expect(text).toHaveLength(60)
  106. expect(text).toContain('hover truncated (limit 60 characters).')
  107. })
  108. it('still honors a cap smaller than the truncation marker', () => {
  109. expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10)
  110. })
  111. })
  112. describe('presentLspCall', () => {
  113. it('is a generic search card with an operation/cursor title and a line location', () => {
  114. expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
  115. card: 'generic',
  116. kind: 'search',
  117. title: 'LSP findReferences a.ts:3:7',
  118. locations: [{ path: 'a.ts', line: 3 }],
  119. })
  120. })
  121. })