detect.ts 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. /**
  2. * Trigger detection pure core. Scans backward from
  3. * the caret for a live trigger char under the guard tier and applies the
  4. * word-boundary rules. Zero React / DOM / cordis.
  5. */
  6. import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar'
  7. import type { TriggerChar } from '../types.ts'
  8. import type { DetectTrigger } from './contract.ts'
  9. const WORD_CHAR = /[\p{L}\p{N}_]/u
  10. const WHITESPACE = /\s/u
  11. /**
  12. * Word-boundary rule: a trigger char opens only at start-of-draft, after
  13. * whitespace (newlines included), or after punctuation. Two URL carve-outs
  14. * keep '/' dead inside URLs (both pinned by tests): '/' after a ':' that
  15. * itself follows a non-whitespace char (scheme separator, `https:/…`), and
  16. * '/' directly after another '/' (second slash of `//`).
  17. */
  18. function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
  19. if (index === 0) return true
  20. const prev = draft.charAt(index - 1)
  21. if (WHITESPACE.test(prev)) return true
  22. if (WORD_CHAR.test(prev)) return false
  23. if (char === '/') {
  24. if (prev === '/') return false
  25. if (prev === ':' && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false
  26. }
  27. return true
  28. }
  29. /**
  30. * Detect a trigger token at the caret. `@` first uses the shared grammar,
  31. * including an open quoted token that may span whitespace. Slash detection
  32. * scans left to the first whitespace; slashes failing the word boundary are
  33. * treated as ordinary token chars and the scan continues (URL slashes).
  34. * Guard tiers: plain = both chars live; claimed = '/' fully suppressed,
  35. * '@' live; frozen = none.
  36. *
  37. * @param draft - Full draft text.
  38. * @param caret - Caret offset into `draft`.
  39. * @param guard - Availability tier derived from the input phase.
  40. * @returns The hit with `query` = trigger-to-caret slice and `span` =
  41. * `{start: triggerIndex, end: caret}`; `span.draftRev` is a placeholder `0`
  42. * — the calling shell stamps the real revision. Null when no trigger is
  43. * live at the caret.
  44. */
  45. export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
  46. if (guard.tier === 'frozen') return null
  47. const at = activeAtToken(draft, caret)
  48. if (at !== undefined) {
  49. const start = caret - at.prefix.length
  50. return {
  51. trigger: '@',
  52. query: at.query,
  53. quoted: at.quoted,
  54. position: draft.search(/\S/) === start ? 'leading' : 'inline',
  55. span: { start, end: caret, draftRev: 0 },
  56. }
  57. }
  58. for (let i = caret - 1; i >= 0; i--) {
  59. const ch = draft.charAt(i)
  60. if (WHITESPACE.test(ch)) return null
  61. if (ch !== '/') continue
  62. if (guard.tier === 'claimed') continue
  63. if (!boundaryOk(draft, i, ch)) continue
  64. return {
  65. trigger: ch,
  66. query: draft.slice(i + 1, caret),
  67. quoted: false,
  68. position: draft.search(/\S/) === i ? 'leading' : 'inline',
  69. span: { start: i, end: caret, draftRev: 0 },
  70. }
  71. }
  72. return null
  73. }