tailwind-typescript-plugin 1.4.0-beta.11 → 1.4.0-beta.13
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/CHANGELOG.md +12 -0
- package/lib/plugin/TailwindTypescriptPlugin.d.ts +4 -0
- package/lib/plugin/TailwindTypescriptPlugin.d.ts.map +1 -1
- package/lib/plugin/TailwindTypescriptPlugin.js +33 -1
- package/lib/plugin/TailwindTypescriptPlugin.js.map +1 -1
- package/lib/services/CompletionService.d.ts +27 -2
- package/lib/services/CompletionService.d.ts.map +1 -1
- package/lib/services/CompletionService.js +117 -17
- package/lib/services/CompletionService.js.map +1 -1
- package/lib/services/CompletionService.spec.js +850 -1
- package/lib/services/CompletionService.spec.js.map +1 -1
- package/package.json +1 -1
|
@@ -37,6 +37,11 @@ const ts = __importStar(require("typescript/lib/tsserverlibrary"));
|
|
|
37
37
|
const TailwindValidator_1 = require("../infrastructure/TailwindValidator");
|
|
38
38
|
const Logger_1 = require("../utils/Logger");
|
|
39
39
|
const CompletionService_1 = require("./CompletionService");
|
|
40
|
+
const defaultConfig = {
|
|
41
|
+
utilityFunctions: ['clsx', 'cn', 'classnames', 'classNames', 'cx', 'twMerge'],
|
|
42
|
+
tailwindVariantsEnabled: true,
|
|
43
|
+
classVarianceAuthorityEnabled: true
|
|
44
|
+
};
|
|
40
45
|
describe('CompletionService', () => {
|
|
41
46
|
let validator;
|
|
42
47
|
let completionService;
|
|
@@ -74,7 +79,7 @@ describe('CompletionService', () => {
|
|
|
74
79
|
return null;
|
|
75
80
|
});
|
|
76
81
|
});
|
|
77
|
-
completionService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger());
|
|
82
|
+
completionService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), defaultConfig);
|
|
78
83
|
});
|
|
79
84
|
describe('getCompletionsAtPosition', () => {
|
|
80
85
|
it('should return original completions when not in className context', () => {
|
|
@@ -330,5 +335,849 @@ describe('CompletionService', () => {
|
|
|
330
335
|
expect(names).not.toContain('flex');
|
|
331
336
|
});
|
|
332
337
|
});
|
|
338
|
+
describe('custom utility functions', () => {
|
|
339
|
+
it('should provide completions in custom utility function', () => {
|
|
340
|
+
const customConfig = {
|
|
341
|
+
utilityFunctions: ['myCustomClass', { name: 'styles', from: '@/utils' }],
|
|
342
|
+
tailwindVariantsEnabled: false,
|
|
343
|
+
classVarianceAuthorityEnabled: false
|
|
344
|
+
};
|
|
345
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
346
|
+
const sourceCode = 'const x = myCustomClass("fl");';
|
|
347
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
348
|
+
// Position after "fl"
|
|
349
|
+
const position = 27;
|
|
350
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
351
|
+
expect(result).toBeDefined();
|
|
352
|
+
const names = result.entries.map(e => e.name);
|
|
353
|
+
expect(names).toContain('flex');
|
|
354
|
+
});
|
|
355
|
+
it('should provide completions in tv() when tailwindVariants is enabled', () => {
|
|
356
|
+
const sourceCode = 'const button = tv({ base: "fl" });';
|
|
357
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
358
|
+
// Position at "l" inside "fl" string (position 28)
|
|
359
|
+
// const button = tv({ base: "fl" });
|
|
360
|
+
// ^^ position 27-28
|
|
361
|
+
const position = 28;
|
|
362
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
363
|
+
expect(result).toBeDefined();
|
|
364
|
+
const names = result.entries.map(e => e.name);
|
|
365
|
+
expect(names).toContain('flex');
|
|
366
|
+
});
|
|
367
|
+
it('should NOT provide completions in tv() when tailwindVariants is disabled', () => {
|
|
368
|
+
const customConfig = {
|
|
369
|
+
utilityFunctions: ['clsx'],
|
|
370
|
+
tailwindVariantsEnabled: false,
|
|
371
|
+
classVarianceAuthorityEnabled: false
|
|
372
|
+
};
|
|
373
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
374
|
+
const sourceCode = 'const button = tv({ base: "fl" });';
|
|
375
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
376
|
+
// Position after "fl" inside the string
|
|
377
|
+
const position = 29;
|
|
378
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
379
|
+
// Should not provide Tailwind completions since tv() is not recognized
|
|
380
|
+
expect(result).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
it('should provide completions in cva() when classVarianceAuthority is enabled', () => {
|
|
383
|
+
const sourceCode = 'const button = cva("fl");';
|
|
384
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
385
|
+
// Position after "fl"
|
|
386
|
+
const position = 22;
|
|
387
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
388
|
+
expect(result).toBeDefined();
|
|
389
|
+
const names = result.entries.map(e => e.name);
|
|
390
|
+
expect(names).toContain('flex');
|
|
391
|
+
});
|
|
392
|
+
it('should NOT provide completions in cva() when classVarianceAuthority is disabled', () => {
|
|
393
|
+
const customConfig = {
|
|
394
|
+
utilityFunctions: ['clsx'],
|
|
395
|
+
tailwindVariantsEnabled: false,
|
|
396
|
+
classVarianceAuthorityEnabled: false
|
|
397
|
+
};
|
|
398
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
399
|
+
const sourceCode = 'const button = cva("fl");';
|
|
400
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
401
|
+
// Position after "fl"
|
|
402
|
+
const position = 22;
|
|
403
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
404
|
+
// Should not provide Tailwind completions since cva() is not recognized
|
|
405
|
+
expect(result).toBeUndefined();
|
|
406
|
+
});
|
|
407
|
+
it('should provide completions with UtilityFunctionConfig objects', () => {
|
|
408
|
+
const customConfig = {
|
|
409
|
+
utilityFunctions: [{ name: 'customStyles', from: '@/lib/styles' }],
|
|
410
|
+
tailwindVariantsEnabled: false,
|
|
411
|
+
classVarianceAuthorityEnabled: false
|
|
412
|
+
};
|
|
413
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
414
|
+
const sourceCode = 'const x = customStyles("fl");';
|
|
415
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
416
|
+
const position = 26;
|
|
417
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
418
|
+
expect(result).toBeDefined();
|
|
419
|
+
const names = result.entries.map(e => e.name);
|
|
420
|
+
expect(names).toContain('flex');
|
|
421
|
+
});
|
|
422
|
+
it('should provide completions with mixed string and object utility functions', () => {
|
|
423
|
+
const customConfig = {
|
|
424
|
+
utilityFunctions: ['simpleUtil', { name: 'configuredUtil', from: '@/utils' }],
|
|
425
|
+
tailwindVariantsEnabled: false,
|
|
426
|
+
classVarianceAuthorityEnabled: false
|
|
427
|
+
};
|
|
428
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
429
|
+
// Test simple string utility
|
|
430
|
+
const sourceCode1 = 'const x = simpleUtil("fl");';
|
|
431
|
+
const sourceFile1 = ts.createSourceFile('test.tsx', sourceCode1, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
432
|
+
const result1 = customService.getCompletionsAtPosition(ts, sourceFile1, 24, undefined);
|
|
433
|
+
expect(result1).toBeDefined();
|
|
434
|
+
expect(result1.entries.map(e => e.name)).toContain('flex');
|
|
435
|
+
// Test configured utility
|
|
436
|
+
const sourceCode2 = 'const x = configuredUtil("fl");';
|
|
437
|
+
const sourceFile2 = ts.createSourceFile('test.tsx', sourceCode2, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
438
|
+
const result2 = customService.getCompletionsAtPosition(ts, sourceFile2, 28, undefined);
|
|
439
|
+
expect(result2).toBeDefined();
|
|
440
|
+
expect(result2.entries.map(e => e.name)).toContain('flex');
|
|
441
|
+
});
|
|
442
|
+
it('should provide completions in tv() variants property', () => {
|
|
443
|
+
const sourceCode = `const button = tv({
|
|
444
|
+
base: "flex",
|
|
445
|
+
variants: {
|
|
446
|
+
color: {
|
|
447
|
+
primary: "bg-blue-500 te",
|
|
448
|
+
secondary: "bg-gray-500"
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});`;
|
|
452
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
453
|
+
// Find position after "te" in primary variant
|
|
454
|
+
const position = sourceCode.indexOf('te",') + 2;
|
|
455
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
456
|
+
expect(result).toBeDefined();
|
|
457
|
+
const names = result.entries.map(e => e.name);
|
|
458
|
+
expect(names).toContain('text-white');
|
|
459
|
+
expect(names).toContain('text-black');
|
|
460
|
+
});
|
|
461
|
+
it('should provide completions in cva() variants property', () => {
|
|
462
|
+
const sourceCode = `const button = cva("base-class", {
|
|
463
|
+
variants: {
|
|
464
|
+
size: {
|
|
465
|
+
sm: "p-",
|
|
466
|
+
lg: "p-4"
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
});`;
|
|
470
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
471
|
+
// Find position after "p-" in sm variant
|
|
472
|
+
const position = sourceCode.indexOf('p-",') + 2;
|
|
473
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
474
|
+
expect(result).toBeDefined();
|
|
475
|
+
const names = result.entries.map(e => e.name);
|
|
476
|
+
// Should match classes starting with "p-" from the mock (p-4, px-4, py-4)
|
|
477
|
+
expect(names).toContain('p-4');
|
|
478
|
+
});
|
|
479
|
+
it('should provide completions in tv() compoundVariants', () => {
|
|
480
|
+
const sourceCode = `const button = tv({
|
|
481
|
+
base: "flex",
|
|
482
|
+
variants: {
|
|
483
|
+
color: { primary: "bg-blue-500" }
|
|
484
|
+
},
|
|
485
|
+
compoundVariants: [
|
|
486
|
+
{ color: "primary", class: "fl" }
|
|
487
|
+
]
|
|
488
|
+
});`;
|
|
489
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
490
|
+
// Find position after "fl" in compoundVariants class
|
|
491
|
+
const position = sourceCode.indexOf('"fl"') + 3;
|
|
492
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
493
|
+
expect(result).toBeDefined();
|
|
494
|
+
const names = result.entries.map(e => e.name);
|
|
495
|
+
expect(names).toContain('flex');
|
|
496
|
+
});
|
|
497
|
+
it('should enable both tv and cva when both variant options are true', () => {
|
|
498
|
+
const customConfig = {
|
|
499
|
+
utilityFunctions: [],
|
|
500
|
+
tailwindVariantsEnabled: true,
|
|
501
|
+
classVarianceAuthorityEnabled: true
|
|
502
|
+
};
|
|
503
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
504
|
+
// Test tv()
|
|
505
|
+
const tvSource = 'const x = tv({ base: "fl" });';
|
|
506
|
+
const tvFile = ts.createSourceFile('test.tsx', tvSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
507
|
+
const tvResult = customService.getCompletionsAtPosition(ts, tvFile, 24, undefined);
|
|
508
|
+
expect(tvResult).toBeDefined();
|
|
509
|
+
// Test cva()
|
|
510
|
+
const cvaSource = 'const x = cva("fl");';
|
|
511
|
+
const cvaFile = ts.createSourceFile('test.tsx', cvaSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
512
|
+
const cvaResult = customService.getCompletionsAtPosition(ts, cvaFile, 17, undefined);
|
|
513
|
+
expect(cvaResult).toBeDefined();
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
describe('JSX attribute variations', () => {
|
|
517
|
+
it('should provide completions in "class" attribute', () => {
|
|
518
|
+
const sourceCode = '<div class="fl">Hello</div>';
|
|
519
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
520
|
+
const position = 14;
|
|
521
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
522
|
+
expect(result).toBeDefined();
|
|
523
|
+
const names = result.entries.map(e => e.name);
|
|
524
|
+
expect(names).toContain('flex');
|
|
525
|
+
});
|
|
526
|
+
it('should provide completions in "classList" attribute', () => {
|
|
527
|
+
const sourceCode = '<div classList="fl">Hello</div>';
|
|
528
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
529
|
+
const position = 18;
|
|
530
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
531
|
+
expect(result).toBeDefined();
|
|
532
|
+
const names = result.entries.map(e => e.name);
|
|
533
|
+
expect(names).toContain('flex');
|
|
534
|
+
});
|
|
535
|
+
it('should NOT provide completions in non-className attributes', () => {
|
|
536
|
+
const sourceCode = '<div id="fl">Hello</div>';
|
|
537
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
538
|
+
const position = 11;
|
|
539
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
540
|
+
expect(result).toBeUndefined();
|
|
541
|
+
});
|
|
542
|
+
it('should NOT provide completions in data attributes', () => {
|
|
543
|
+
const sourceCode = '<div data-class="fl">Hello</div>';
|
|
544
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
545
|
+
const position = 19;
|
|
546
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
547
|
+
expect(result).toBeUndefined();
|
|
548
|
+
});
|
|
549
|
+
it('should provide completions in self-closing JSX element', () => {
|
|
550
|
+
const sourceCode = '<input className="fl" />';
|
|
551
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
552
|
+
const position = 20;
|
|
553
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
554
|
+
expect(result).toBeDefined();
|
|
555
|
+
const names = result.entries.map(e => e.name);
|
|
556
|
+
expect(names).toContain('flex');
|
|
557
|
+
});
|
|
558
|
+
it('should provide completions in nested JSX elements', () => {
|
|
559
|
+
const sourceCode = '<div><span className="fl"></span></div>';
|
|
560
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
561
|
+
const position = 24;
|
|
562
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
563
|
+
expect(result).toBeDefined();
|
|
564
|
+
const names = result.entries.map(e => e.name);
|
|
565
|
+
expect(names).toContain('flex');
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
describe('utility function contexts', () => {
|
|
569
|
+
it('should provide completions in nested utility function calls', () => {
|
|
570
|
+
const sourceCode = 'const x = cn(clsx("fl"));';
|
|
571
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
572
|
+
const position = 21;
|
|
573
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
574
|
+
expect(result).toBeDefined();
|
|
575
|
+
const names = result.entries.map(e => e.name);
|
|
576
|
+
expect(names).toContain('flex');
|
|
577
|
+
});
|
|
578
|
+
it('should provide completions in twMerge function', () => {
|
|
579
|
+
const sourceCode = 'const x = twMerge("fl");';
|
|
580
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
581
|
+
const position = 21;
|
|
582
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
583
|
+
expect(result).toBeDefined();
|
|
584
|
+
const names = result.entries.map(e => e.name);
|
|
585
|
+
expect(names).toContain('flex');
|
|
586
|
+
});
|
|
587
|
+
it('should provide completions in classnames function (lowercase)', () => {
|
|
588
|
+
const sourceCode = 'const x = classnames("fl");';
|
|
589
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
590
|
+
const position = 24;
|
|
591
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
592
|
+
expect(result).toBeDefined();
|
|
593
|
+
const names = result.entries.map(e => e.name);
|
|
594
|
+
expect(names).toContain('flex');
|
|
595
|
+
});
|
|
596
|
+
it('should provide completions in classNames function (camelCase)', () => {
|
|
597
|
+
const sourceCode = 'const x = classNames("fl");';
|
|
598
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
599
|
+
const position = 24;
|
|
600
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
601
|
+
expect(result).toBeDefined();
|
|
602
|
+
const names = result.entries.map(e => e.name);
|
|
603
|
+
expect(names).toContain('flex');
|
|
604
|
+
});
|
|
605
|
+
it('should provide completions in cx function', () => {
|
|
606
|
+
const sourceCode = 'const x = cx("fl");';
|
|
607
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
608
|
+
const position = 16;
|
|
609
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
610
|
+
expect(result).toBeDefined();
|
|
611
|
+
const names = result.entries.map(e => e.name);
|
|
612
|
+
expect(names).toContain('flex');
|
|
613
|
+
});
|
|
614
|
+
it('should provide completions in conditional utility function argument', () => {
|
|
615
|
+
const sourceCode = 'const x = cn(isActive ? "fl" : "hidden");';
|
|
616
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
617
|
+
// Position inside the first string "fl"
|
|
618
|
+
const position = 27;
|
|
619
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
620
|
+
expect(result).toBeDefined();
|
|
621
|
+
const names = result.entries.map(e => e.name);
|
|
622
|
+
expect(names).toContain('flex');
|
|
623
|
+
});
|
|
624
|
+
it('should provide completions in array argument of utility function', () => {
|
|
625
|
+
const sourceCode = 'const x = cn(["fl", "items-center"]);';
|
|
626
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
627
|
+
// Position inside "fl"
|
|
628
|
+
const position = 17;
|
|
629
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
630
|
+
expect(result).toBeDefined();
|
|
631
|
+
const names = result.entries.map(e => e.name);
|
|
632
|
+
expect(names).toContain('flex');
|
|
633
|
+
});
|
|
634
|
+
it('should provide completions in object value of utility function', () => {
|
|
635
|
+
const sourceCode = 'const x = cn({ "fl": isActive });';
|
|
636
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
637
|
+
// Position inside "fl" key
|
|
638
|
+
const position = 18;
|
|
639
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
640
|
+
expect(result).toBeDefined();
|
|
641
|
+
const names = result.entries.map(e => e.name);
|
|
642
|
+
expect(names).toContain('flex');
|
|
643
|
+
});
|
|
644
|
+
it('should NOT provide completions in unknown function', () => {
|
|
645
|
+
const sourceCode = 'const x = unknownFn("fl");';
|
|
646
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
647
|
+
const position = 23;
|
|
648
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
649
|
+
expect(result).toBeUndefined();
|
|
650
|
+
});
|
|
651
|
+
it('should provide completions with property access expression (method call)', () => {
|
|
652
|
+
// When cn is called as a method: utils.cn("fl")
|
|
653
|
+
const customConfig = {
|
|
654
|
+
utilityFunctions: ['cn'],
|
|
655
|
+
tailwindVariantsEnabled: false,
|
|
656
|
+
classVarianceAuthorityEnabled: false
|
|
657
|
+
};
|
|
658
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
659
|
+
const sourceCode = 'const x = utils.cn("fl");';
|
|
660
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
661
|
+
const position = 22;
|
|
662
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
663
|
+
expect(result).toBeDefined();
|
|
664
|
+
const names = result.entries.map(e => e.name);
|
|
665
|
+
expect(names).toContain('flex');
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
describe('edge cases and position handling', () => {
|
|
669
|
+
it('should handle empty className attribute', () => {
|
|
670
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
671
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
672
|
+
// Position inside empty string
|
|
673
|
+
const position = 16;
|
|
674
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
675
|
+
expect(result).toBeDefined();
|
|
676
|
+
expect(result.entries.length).toBeGreaterThan(0);
|
|
677
|
+
});
|
|
678
|
+
it('should handle position at start of string content', () => {
|
|
679
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
680
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
681
|
+
// Position at start of string content (right after opening quote)
|
|
682
|
+
const position = 16;
|
|
683
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
684
|
+
expect(result).toBeDefined();
|
|
685
|
+
});
|
|
686
|
+
it('should handle multiple spaces between classes', () => {
|
|
687
|
+
const sourceCode = '<div className="flex items-center">Hello</div>';
|
|
688
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
689
|
+
// Position after multiple spaces
|
|
690
|
+
const position = 23;
|
|
691
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
692
|
+
expect(result).toBeDefined();
|
|
693
|
+
});
|
|
694
|
+
it('should handle trailing space in className', () => {
|
|
695
|
+
const sourceCode = '<div className="flex ">Hello</div>';
|
|
696
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
697
|
+
// Position after trailing space
|
|
698
|
+
const position = 21;
|
|
699
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
700
|
+
expect(result).toBeDefined();
|
|
701
|
+
// flex should be excluded as it's already in the string
|
|
702
|
+
const names = result.entries.map(e => e.name);
|
|
703
|
+
expect(names).not.toContain('flex');
|
|
704
|
+
});
|
|
705
|
+
it('should return existingCompletions when no Tailwind classes match', () => {
|
|
706
|
+
// Mock empty class list
|
|
707
|
+
jest.spyOn(validator, 'getAllClasses').mockReturnValue([]);
|
|
708
|
+
const existingCompletions = {
|
|
709
|
+
isGlobalCompletion: false,
|
|
710
|
+
isMemberCompletion: false,
|
|
711
|
+
isNewIdentifierLocation: false,
|
|
712
|
+
entries: [{ name: 'existing', kind: ts.ScriptElementKind.unknown, sortText: '0' }]
|
|
713
|
+
};
|
|
714
|
+
const sourceCode = '<div className="xyz">Hello</div>';
|
|
715
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
716
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 19, existingCompletions);
|
|
717
|
+
expect(result).toBe(existingCompletions);
|
|
718
|
+
});
|
|
719
|
+
it('should handle cursor position before string start', () => {
|
|
720
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
721
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
722
|
+
// Position before the opening quote
|
|
723
|
+
const position = 14;
|
|
724
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
725
|
+
// Should not provide completions when cursor is outside string
|
|
726
|
+
expect(result).toBeUndefined();
|
|
727
|
+
});
|
|
728
|
+
it('should handle very long class names', () => {
|
|
729
|
+
jest
|
|
730
|
+
.spyOn(validator, 'getAllClasses')
|
|
731
|
+
.mockReturnValue(['very-long-tailwind-class-name-that-is-quite-long', 'flex']);
|
|
732
|
+
const sourceCode = '<div className="very">Hello</div>';
|
|
733
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
734
|
+
const position = 20;
|
|
735
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
736
|
+
expect(result).toBeDefined();
|
|
737
|
+
const names = result.entries.map(e => e.name);
|
|
738
|
+
expect(names).toContain('very-long-tailwind-class-name-that-is-quite-long');
|
|
739
|
+
});
|
|
740
|
+
it('should handle special characters in class prefix', () => {
|
|
741
|
+
jest
|
|
742
|
+
.spyOn(validator, 'getAllClasses')
|
|
743
|
+
.mockReturnValue(['w-1/2', 'w-1/3', 'w-1/4', '-mt-4', '-translate-x-1/2']);
|
|
744
|
+
const sourceCode = '<div className="w-1/">Hello</div>';
|
|
745
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
746
|
+
const position = 20;
|
|
747
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
748
|
+
expect(result).toBeDefined();
|
|
749
|
+
const names = result.entries.map(e => e.name);
|
|
750
|
+
expect(names).toContain('w-1/2');
|
|
751
|
+
expect(names).toContain('w-1/3');
|
|
752
|
+
expect(names).toContain('w-1/4');
|
|
753
|
+
});
|
|
754
|
+
it('should handle negative value classes', () => {
|
|
755
|
+
jest.spyOn(validator, 'getAllClasses').mockReturnValue(['-mt-4', '-mb-4', '-ml-4', '-mr-4']);
|
|
756
|
+
const sourceCode = '<div className="-m">Hello</div>';
|
|
757
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
758
|
+
const position = 18;
|
|
759
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
760
|
+
expect(result).toBeDefined();
|
|
761
|
+
const names = result.entries.map(e => e.name);
|
|
762
|
+
expect(names).toContain('-mt-4');
|
|
763
|
+
expect(names).toContain('-mb-4');
|
|
764
|
+
});
|
|
765
|
+
it('should handle arbitrary value classes', () => {
|
|
766
|
+
jest
|
|
767
|
+
.spyOn(validator, 'getAllClasses')
|
|
768
|
+
.mockReturnValue(['w-[100px]', 'h-[50vh]', 'bg-[#ff0000]']);
|
|
769
|
+
const sourceCode = '<div className="w-[">Hello</div>';
|
|
770
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
771
|
+
const position = 19;
|
|
772
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
773
|
+
expect(result).toBeDefined();
|
|
774
|
+
const names = result.entries.map(e => e.name);
|
|
775
|
+
expect(names).toContain('w-[100px]');
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
describe('sort text ordering', () => {
|
|
779
|
+
it('should prioritize exact matches', () => {
|
|
780
|
+
jest.spyOn(validator, 'getAllClasses').mockReturnValue(['flex', 'flex-row', 'flex-col']);
|
|
781
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
782
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
783
|
+
const position = 20;
|
|
784
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
785
|
+
expect(result).toBeDefined();
|
|
786
|
+
const flexEntry = result.entries.find(e => e.name === 'flex');
|
|
787
|
+
const flexRowEntry = result.entries.find(e => e.name === 'flex-row');
|
|
788
|
+
// Exact match should have lower sortText (higher priority)
|
|
789
|
+
expect(flexEntry.sortText < flexRowEntry.sortText).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
it('should prioritize prefix matches over non-matches', () => {
|
|
792
|
+
jest
|
|
793
|
+
.spyOn(validator, 'getAllClasses')
|
|
794
|
+
.mockReturnValue(['flex', 'flex-row', 'items-center', 'justify-center']);
|
|
795
|
+
const sourceCode = '<div className="fl">Hello</div>';
|
|
796
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
797
|
+
const position = 18;
|
|
798
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
799
|
+
expect(result).toBeDefined();
|
|
800
|
+
const flexEntry = result.entries.find(e => e.name === 'flex');
|
|
801
|
+
// Prefix match should start with "1"
|
|
802
|
+
expect(flexEntry.sortText.startsWith('1')).toBe(true);
|
|
803
|
+
});
|
|
804
|
+
it('should sort alphabetically within same priority', () => {
|
|
805
|
+
jest.spyOn(validator, 'getAllClasses').mockReturnValue(['flex-col', 'flex-row', 'flex']);
|
|
806
|
+
const sourceCode = '<div className="flex-">Hello</div>';
|
|
807
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
808
|
+
const position = 21;
|
|
809
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
810
|
+
expect(result).toBeDefined();
|
|
811
|
+
const names = result.entries.map(e => e.name);
|
|
812
|
+
// Both should be prefix matches with alphabetical ordering
|
|
813
|
+
const colIndex = names.indexOf('flex-col');
|
|
814
|
+
const rowIndex = names.indexOf('flex-row');
|
|
815
|
+
expect(colIndex).toBeLessThan(rowIndex);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
describe('color class detection - additional patterns', () => {
|
|
819
|
+
beforeEach(() => {
|
|
820
|
+
jest
|
|
821
|
+
.spyOn(validator, 'getAllClasses')
|
|
822
|
+
.mockReturnValue([
|
|
823
|
+
'bg-red-500',
|
|
824
|
+
'bg-red-500/50',
|
|
825
|
+
'bg-[#ff0000]',
|
|
826
|
+
'bg-gradient-to-r',
|
|
827
|
+
'text-transparent',
|
|
828
|
+
'text-current',
|
|
829
|
+
'text-inherit',
|
|
830
|
+
'placeholder-gray-400',
|
|
831
|
+
'caret-blue-500',
|
|
832
|
+
'decoration-pink-500',
|
|
833
|
+
'divide-slate-200',
|
|
834
|
+
'ring-offset-white',
|
|
835
|
+
'shadow-black/25',
|
|
836
|
+
'bg-opacity-50',
|
|
837
|
+
'text-opacity-75'
|
|
838
|
+
]);
|
|
839
|
+
});
|
|
840
|
+
it('should detect color classes with opacity modifiers', () => {
|
|
841
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
842
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
843
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 16, undefined);
|
|
844
|
+
expect(result).toBeDefined();
|
|
845
|
+
expect(result.entries.find(e => e.name === 'bg-red-500/50')?.kindModifiers).toBe('color');
|
|
846
|
+
expect(result.entries.find(e => e.name === 'shadow-black/25')?.kindModifiers).toBe('color');
|
|
847
|
+
});
|
|
848
|
+
it('should detect arbitrary color values', () => {
|
|
849
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
850
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
851
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 16, undefined);
|
|
852
|
+
expect(result).toBeDefined();
|
|
853
|
+
expect(result.entries.find(e => e.name === 'bg-[#ff0000]')?.kindModifiers).toBe('color');
|
|
854
|
+
});
|
|
855
|
+
it('should detect special color values (transparent, current, inherit)', () => {
|
|
856
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
857
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
858
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 16, undefined);
|
|
859
|
+
expect(result).toBeDefined();
|
|
860
|
+
expect(result.entries.find(e => e.name === 'text-transparent')?.kindModifiers).toBe('color');
|
|
861
|
+
expect(result.entries.find(e => e.name === 'text-current')?.kindModifiers).toBe('color');
|
|
862
|
+
expect(result.entries.find(e => e.name === 'text-inherit')?.kindModifiers).toBe('color');
|
|
863
|
+
});
|
|
864
|
+
it('should detect placeholder, caret, and decoration colors', () => {
|
|
865
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
866
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
867
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 16, undefined);
|
|
868
|
+
expect(result).toBeDefined();
|
|
869
|
+
expect(result.entries.find(e => e.name === 'placeholder-gray-400')?.kindModifiers).toBe('color');
|
|
870
|
+
expect(result.entries.find(e => e.name === 'caret-blue-500')?.kindModifiers).toBe('color');
|
|
871
|
+
expect(result.entries.find(e => e.name === 'decoration-pink-500')?.kindModifiers).toBe('color');
|
|
872
|
+
});
|
|
873
|
+
it('should NOT mark gradient direction as color', () => {
|
|
874
|
+
const sourceCode = '<div className="">Hello</div>';
|
|
875
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
876
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, 16, undefined);
|
|
877
|
+
expect(result).toBeDefined();
|
|
878
|
+
// bg-gradient-to-r is not a color, it's a gradient direction
|
|
879
|
+
expect(result.entries.find(e => e.name === 'bg-gradient-to-r')?.kindModifiers).toBe('');
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
describe('getCompletionEntryDetails - additional cases', () => {
|
|
883
|
+
it('should set color kindModifier in completion details', () => {
|
|
884
|
+
jest
|
|
885
|
+
.spyOn(validator, 'getCssForClasses')
|
|
886
|
+
.mockReturnValue(['.bg-red-500 { background-color: rgb(239 68 68); }']);
|
|
887
|
+
const sourceCode = '<div className="bg-red-500">Hello</div>';
|
|
888
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
889
|
+
const result = completionService.getCompletionEntryDetails(ts, sourceFile, 16, 'bg-red-500');
|
|
890
|
+
expect(result).toBeDefined();
|
|
891
|
+
expect(result.kindModifiers).toBe('color');
|
|
892
|
+
});
|
|
893
|
+
it('should NOT set color kindModifier for non-color class in details', () => {
|
|
894
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
895
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
896
|
+
const result = completionService.getCompletionEntryDetails(ts, sourceFile, 16, 'flex');
|
|
897
|
+
expect(result).toBeDefined();
|
|
898
|
+
expect(result.kindModifiers).toBe('');
|
|
899
|
+
});
|
|
900
|
+
it('should handle class with no CSS definition gracefully', () => {
|
|
901
|
+
jest.spyOn(validator, 'getCssForClasses').mockReturnValue([null]);
|
|
902
|
+
jest.spyOn(validator, 'isValidClass').mockReturnValue(true);
|
|
903
|
+
const sourceCode = '<div className="unknown-class">Hello</div>';
|
|
904
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
905
|
+
const result = completionService.getCompletionEntryDetails(ts, sourceFile, 16, 'unknown-class');
|
|
906
|
+
expect(result).toBeDefined();
|
|
907
|
+
expect(result.name).toBe('unknown-class');
|
|
908
|
+
expect(result.documentation).toEqual([]);
|
|
909
|
+
expect(result.displayParts[0].text).toBe('unknown-class');
|
|
910
|
+
});
|
|
911
|
+
it('should format CSS with already formatted multiline input', () => {
|
|
912
|
+
const multilineCss = `.complex {\n display: flex;\n align-items: center;\n}`;
|
|
913
|
+
jest.spyOn(validator, 'getCssForClasses').mockReturnValue([multilineCss]);
|
|
914
|
+
jest.spyOn(validator, 'isValidClass').mockReturnValue(true);
|
|
915
|
+
const sourceCode = '<div className="complex">Hello</div>';
|
|
916
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
917
|
+
const result = completionService.getCompletionEntryDetails(ts, sourceFile, 16, 'complex');
|
|
918
|
+
expect(result).toBeDefined();
|
|
919
|
+
// Should preserve the already formatted CSS
|
|
920
|
+
expect(result.documentation[0].text).toContain(multilineCss);
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
describe('template literals', () => {
|
|
924
|
+
it('should provide completions in template literal utility function', () => {
|
|
925
|
+
const sourceCode = 'const x = cn(`fl`);';
|
|
926
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
927
|
+
// Position inside template literal
|
|
928
|
+
const position = 16;
|
|
929
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
930
|
+
expect(result).toBeDefined();
|
|
931
|
+
const names = result.entries.map(e => e.name);
|
|
932
|
+
expect(names).toContain('flex');
|
|
933
|
+
});
|
|
934
|
+
it('should provide completions in NoSubstitutionTemplateLiteral inside utility function', () => {
|
|
935
|
+
const sourceCode = 'const cls = clsx(`flex fl`);';
|
|
936
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
937
|
+
// Position after "fl" inside template literal
|
|
938
|
+
const position = 25;
|
|
939
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
940
|
+
expect(result).toBeDefined();
|
|
941
|
+
const names = result.entries.map(e => e.name);
|
|
942
|
+
expect(names).toContain('flex-row');
|
|
943
|
+
expect(names).toContain('flex-col');
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
describe('boundary conditions', () => {
|
|
947
|
+
it('should NOT provide completions inside arrow function body', () => {
|
|
948
|
+
const customConfig = {
|
|
949
|
+
utilityFunctions: [],
|
|
950
|
+
tailwindVariantsEnabled: false,
|
|
951
|
+
classVarianceAuthorityEnabled: false
|
|
952
|
+
};
|
|
953
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
954
|
+
const sourceCode = 'const fn = () => { const x = "fl"; };';
|
|
955
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
956
|
+
// Position inside string in arrow function (not a utility function context)
|
|
957
|
+
const position = 32;
|
|
958
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
959
|
+
expect(result).toBeUndefined();
|
|
960
|
+
});
|
|
961
|
+
it('should NOT provide completions inside regular function body', () => {
|
|
962
|
+
const customConfig = {
|
|
963
|
+
utilityFunctions: [],
|
|
964
|
+
tailwindVariantsEnabled: false,
|
|
965
|
+
classVarianceAuthorityEnabled: false
|
|
966
|
+
};
|
|
967
|
+
const customService = new CompletionService_1.CompletionService(validator, new Logger_1.NoOpLogger(), customConfig);
|
|
968
|
+
const sourceCode = 'function fn() { const x = "fl"; }';
|
|
969
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
970
|
+
// Position inside string in function (not a utility function context)
|
|
971
|
+
const position = 29;
|
|
972
|
+
const result = customService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
973
|
+
expect(result).toBeUndefined();
|
|
974
|
+
});
|
|
975
|
+
it('should provide completions when utility function is inside arrow function', () => {
|
|
976
|
+
const sourceCode = 'const fn = () => cn("fl");';
|
|
977
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
978
|
+
// Position inside cn() string argument
|
|
979
|
+
const position = 23;
|
|
980
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
981
|
+
expect(result).toBeDefined();
|
|
982
|
+
const names = result.entries.map(e => e.name);
|
|
983
|
+
expect(names).toContain('flex');
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
describe('case sensitivity', () => {
|
|
987
|
+
it('should handle case-insensitive prefix matching', () => {
|
|
988
|
+
const sourceCode = '<div className="FL">Hello</div>';
|
|
989
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
990
|
+
const position = 18;
|
|
991
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
992
|
+
expect(result).toBeDefined();
|
|
993
|
+
const names = result.entries.map(e => e.name);
|
|
994
|
+
// Should still match lowercase flex classes even with uppercase prefix
|
|
995
|
+
expect(names).toContain('flex');
|
|
996
|
+
expect(names).toContain('flex-row');
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
describe('replacement span', () => {
|
|
1000
|
+
it('should set correct replacement span at start of string', () => {
|
|
1001
|
+
const sourceCode = '<div className="fl">Hello</div>';
|
|
1002
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1003
|
+
const position = 18;
|
|
1004
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
1005
|
+
expect(result).toBeDefined();
|
|
1006
|
+
const flexEntry = result.entries.find(e => e.name === 'flex');
|
|
1007
|
+
expect(flexEntry.replacementSpan).toEqual({
|
|
1008
|
+
start: 16, // Start of "fl"
|
|
1009
|
+
length: 2 // Length of "fl"
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
it('should set correct replacement span after space', () => {
|
|
1013
|
+
const sourceCode = '<div className="flex ite">Hello</div>';
|
|
1014
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1015
|
+
const position = 24;
|
|
1016
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
1017
|
+
expect(result).toBeDefined();
|
|
1018
|
+
const itemsEntry = result.entries.find(e => e.name === 'items-center');
|
|
1019
|
+
expect(itemsEntry.replacementSpan).toEqual({
|
|
1020
|
+
start: 21, // Start of "ite" (after "flex ")
|
|
1021
|
+
length: 3 // Length of "ite"
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
it('should set zero-length replacement span when no prefix', () => {
|
|
1025
|
+
const sourceCode = '<div className="flex ">Hello</div>';
|
|
1026
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1027
|
+
// Position after space (no prefix yet)
|
|
1028
|
+
const position = 21;
|
|
1029
|
+
const result = completionService.getCompletionsAtPosition(ts, sourceFile, position, undefined);
|
|
1030
|
+
expect(result).toBeDefined();
|
|
1031
|
+
const itemsEntry = result.entries.find(e => e.name === 'items-center');
|
|
1032
|
+
expect(itemsEntry.replacementSpan).toEqual({
|
|
1033
|
+
start: 21,
|
|
1034
|
+
length: 0 // No prefix to replace
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
describe('getQuickInfoAtPosition (hover)', () => {
|
|
1039
|
+
it('should return hover info for valid Tailwind class', () => {
|
|
1040
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1041
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1042
|
+
// Position on "flex"
|
|
1043
|
+
const position = 18;
|
|
1044
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1045
|
+
expect(result).toBeDefined();
|
|
1046
|
+
expect(result.displayParts).toBeDefined();
|
|
1047
|
+
expect(result.displayParts.length).toBeGreaterThan(0);
|
|
1048
|
+
expect(result.displayParts[0].text).toContain('display: flex');
|
|
1049
|
+
});
|
|
1050
|
+
it('should return correct textSpan for hovered class', () => {
|
|
1051
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1052
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1053
|
+
const position = 18;
|
|
1054
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1055
|
+
expect(result).toBeDefined();
|
|
1056
|
+
expect(result.textSpan).toEqual({
|
|
1057
|
+
start: 16, // Start of "flex"
|
|
1058
|
+
length: 4 // Length of "flex"
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
it('should return documentation with CSS code block', () => {
|
|
1062
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1063
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1064
|
+
const position = 18;
|
|
1065
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1066
|
+
expect(result).toBeDefined();
|
|
1067
|
+
expect(result.documentation).toBeDefined();
|
|
1068
|
+
expect(result.documentation.length).toBeGreaterThan(0);
|
|
1069
|
+
const docText = result.documentation[0].text;
|
|
1070
|
+
expect(docText).toContain('```css');
|
|
1071
|
+
expect(docText).toContain('.flex');
|
|
1072
|
+
expect(docText).toContain('display: flex');
|
|
1073
|
+
});
|
|
1074
|
+
it('should return undefined for non-Tailwind class', () => {
|
|
1075
|
+
const sourceCode = '<div className="my-custom-class">Hello</div>';
|
|
1076
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1077
|
+
const position = 20;
|
|
1078
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1079
|
+
expect(result).toBeUndefined();
|
|
1080
|
+
});
|
|
1081
|
+
it('should return undefined when not in className context', () => {
|
|
1082
|
+
const sourceCode = 'const x = "flex";';
|
|
1083
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1084
|
+
const position = 13;
|
|
1085
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1086
|
+
expect(result).toBeUndefined();
|
|
1087
|
+
});
|
|
1088
|
+
it('should set color kindModifier for color classes', () => {
|
|
1089
|
+
jest
|
|
1090
|
+
.spyOn(validator, 'getCssForClasses')
|
|
1091
|
+
.mockReturnValue(['.bg-red-500 { background-color: rgb(239 68 68); }']);
|
|
1092
|
+
const sourceCode = '<div className="bg-red-500">Hello</div>';
|
|
1093
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1094
|
+
const position = 20;
|
|
1095
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1096
|
+
expect(result).toBeDefined();
|
|
1097
|
+
expect(result.kindModifiers).toBe('color');
|
|
1098
|
+
});
|
|
1099
|
+
it('should return hover info for class in utility function', () => {
|
|
1100
|
+
const sourceCode = 'const x = cn("flex items-center");';
|
|
1101
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1102
|
+
// Position on "flex"
|
|
1103
|
+
const position = 16;
|
|
1104
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1105
|
+
expect(result).toBeDefined();
|
|
1106
|
+
expect(result.displayParts[0].text).toContain('display: flex');
|
|
1107
|
+
});
|
|
1108
|
+
it('should return hover info for second class in list', () => {
|
|
1109
|
+
jest.spyOn(validator, 'getCssForClasses').mockImplementation((classNames) => {
|
|
1110
|
+
return classNames.map(name => {
|
|
1111
|
+
if (name === 'flex')
|
|
1112
|
+
return '.flex { display: flex; }';
|
|
1113
|
+
if (name === 'items-center')
|
|
1114
|
+
return '.items-center { align-items: center; }';
|
|
1115
|
+
return null;
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
const sourceCode = '<div className="flex items-center">Hello</div>';
|
|
1119
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1120
|
+
// Position on "items-center"
|
|
1121
|
+
const position = 25;
|
|
1122
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1123
|
+
expect(result).toBeDefined();
|
|
1124
|
+
expect(result.displayParts[0].text).toContain('align-items: center');
|
|
1125
|
+
expect(result.textSpan).toEqual({
|
|
1126
|
+
start: 21, // Start of "items-center"
|
|
1127
|
+
length: 12 // Length of "items-center"
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
it('should return undefined for position on whitespace', () => {
|
|
1131
|
+
const sourceCode = '<div className="flex items-center">Hello</div>';
|
|
1132
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1133
|
+
// Position on the space between classes
|
|
1134
|
+
const position = 21;
|
|
1135
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1136
|
+
// Should return undefined because we're on whitespace, not a class
|
|
1137
|
+
expect(result).toBeUndefined();
|
|
1138
|
+
});
|
|
1139
|
+
it('should return hover info for class at start of string', () => {
|
|
1140
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1141
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1142
|
+
// Position at the very start of "flex"
|
|
1143
|
+
const position = 16;
|
|
1144
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1145
|
+
expect(result).toBeDefined();
|
|
1146
|
+
expect(result.displayParts[0].text).toContain('display: flex');
|
|
1147
|
+
});
|
|
1148
|
+
it('should return hover info for class at end of word', () => {
|
|
1149
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1150
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1151
|
+
// Position at the end of "flex" (just before closing quote)
|
|
1152
|
+
const position = 20;
|
|
1153
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1154
|
+
expect(result).toBeDefined();
|
|
1155
|
+
expect(result.displayParts[0].text).toContain('display: flex');
|
|
1156
|
+
});
|
|
1157
|
+
it('should return undefined when position is outside string', () => {
|
|
1158
|
+
const sourceCode = '<div className="flex">Hello</div>';
|
|
1159
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1160
|
+
// Position on "className" attribute name
|
|
1161
|
+
const position = 10;
|
|
1162
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1163
|
+
expect(result).toBeUndefined();
|
|
1164
|
+
});
|
|
1165
|
+
it('should return hover info in tv() variants', () => {
|
|
1166
|
+
const sourceCode = `const button = tv({
|
|
1167
|
+
base: "flex",
|
|
1168
|
+
variants: {
|
|
1169
|
+
color: {
|
|
1170
|
+
primary: "bg-blue-500"
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
});`;
|
|
1174
|
+
const sourceFile = ts.createSourceFile('test.tsx', sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
1175
|
+
// Position on "flex" in base
|
|
1176
|
+
const position = sourceCode.indexOf('"flex"') + 2;
|
|
1177
|
+
const result = completionService.getQuickInfoAtPosition(ts, sourceFile, position);
|
|
1178
|
+
expect(result).toBeDefined();
|
|
1179
|
+
expect(result.displayParts[0].text).toContain('display: flex');
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
333
1182
|
});
|
|
334
1183
|
//# sourceMappingURL=CompletionService.spec.js.map
|