Skip to content

Commit f804b7c

Browse files
feat(lsp): JRL multi-line builder chains + enum value completion/hover
Problems fixed: 1. In real services.jrl the builder chain spans many lines — each setter on its own line. Hover on `.setJournalType(…)` at line 7 saw only the current line's receiver (`.`, whitespace) and returned null. 2. Typing `foam.dao.JournalType.` offered class IDs, not the enum's own values. 3. Hovering `SINGLE_JOURNAL` inside `foam.dao.JournalType.SINGLE_JOURNAL` showed the JournalType class doc instead of the value's label/ordinal. Changes: - resolveReceiverBefore_ now walks the full embedded content (not a single line). Whitespace is accepted only when it sits between chain elements — i.e. the next non-ws char backward is `.`, `)`, or `]`. This admits multi-line `.setX` continuations while rejecting `new foo.Bar` (ws between keyword + chain). - serviceScriptCompletion_ uses absolute-offset prefix so member match crosses line boundaries. - enumValueCompletions_: if the resolved receiver is an enum class, return its VALUES (CompletionItemKind.EnumMember, sorted by ordinal) BEFORE any member completions. - buildEnumValueHover_: value-level hover with label + ordinal. - resolveDottedClassUnderCursor_: when trimming trailing segments of a dotted identifier, if the stripped suffix is an enum value on the matching class, return the enum-value hover instead of the class doc. Regression tests verified directly on ptv3/journals/services.jrl: - hover on .setPm at line 9 - hover on .setOf at line 14 - hover on SINGLE_JOURNAL Plus synthetic tests: multi-line chain hover on .setSeqNo, .setJournalType, enum value completion ordering (first item is kind=20, not a class). 502 → 505 tests passing.
1 parent 984b3be commit f804b7c

File tree

2 files changed

+239
-35
lines changed

2 files changed

+239
-35
lines changed

tools/lsp/handlers/JrlHandler.js

Lines changed: 134 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -794,57 +794,86 @@ foam.CLASS({
794794
* back once the grammar path is stable and covered by tests.
795795
*/
796796
var cpos = this.embedCursorToPosition_(ctx);
797-
var clines = ctx.content.split('\n');
798-
var line = clines[cpos.line] || '';
797+
var content = ctx.content;
798+
var cursorAbs = this.positionToOffset_(content, cpos);
799+
799800
var wordRe = /[\w.$]/;
800-
var start = cpos.character;
801-
var end = cpos.character;
802-
while ( start > 0 && wordRe.test(line.charAt(start - 1)) ) start--;
803-
while ( end < line.length && wordRe.test(line.charAt(end)) ) end++;
804-
var dotted = line.substring(start, end).replace(/^\.+|\.+$/g, '');
801+
var start = cursorAbs;
802+
var end = cursorAbs;
803+
while ( start > 0 && wordRe.test(content.charAt(start - 1)) ) start--;
804+
while ( end < content.length && wordRe.test(content.charAt(end)) ) end++;
805+
var dotted = content.substring(start, end).replace(/^\.+|\.+$/g, '');
805806

806807
if ( dotted && this.index.classExists(dotted) ) {
807808
return this.index.getClassDoc(dotted);
808809
}
809810
if ( dotted && dotted.indexOf('.') !== -1 ) {
810811
for ( var cand = dotted ; cand.indexOf('.') !== -1 ; ) {
811-
if ( this.index.classExists(cand) ) return this.index.getClassDoc(cand);
812+
if ( this.index.classExists(cand) ) {
813+
// If the trimmed suffix is an enum value on this class, show
814+
// the value hover rather than the class doc.
815+
var suffix = dotted.substring(cand.length + 1);
816+
if ( suffix && suffix.indexOf('.') === -1 ) {
817+
var enumHit = this.buildEnumValueHover_(cand, suffix);
818+
if ( enumHit ) return enumHit;
819+
}
820+
return this.index.getClassDoc(cand);
821+
}
812822
cand = cand.substring(0, cand.lastIndexOf('.'));
813823
}
814824
}
815825
if ( dotted && dotted.indexOf('.') === -1 ) {
816826
// Effective word start: first non-dot char in the matched region.
817827
var effectiveStart = start;
818-
while ( effectiveStart < line.length && line.charAt(effectiveStart) === '.' ) {
828+
while ( effectiveStart < content.length && content.charAt(effectiveStart) === '.' ) {
819829
effectiveStart++;
820830
}
821-
var receiverType = this.resolveReceiverBefore_(line, effectiveStart);
822-
if ( receiverType ) return this.buildMemberHover_(receiverType, dotted);
831+
var receiverType = this.resolveReceiverBefore_(content, effectiveStart);
832+
if ( receiverType ) {
833+
// Check enum first — hovering on ENUM_VALUE inside enum class scope.
834+
var enumHover = this.buildEnumValueHover_(receiverType, dotted);
835+
if ( enumHover ) return enumHover;
836+
return this.buildMemberHover_(receiverType, dotted);
837+
}
823838
}
824839
return null;
825840
},
826841

827-
function resolveReceiverBefore_(line, wordStart) {
842+
function positionToOffset_(content, pos) {
843+
/** Map {line, character} to absolute offset in content. */
844+
var off = 0, line = 0;
845+
for ( var i = 0 ; i < content.length ; i++ ) {
846+
if ( line === pos.line ) return i + pos.character;
847+
if ( content.charCodeAt(i) === 10 ) line++;
848+
}
849+
return content.length;
850+
},
851+
852+
function resolveReceiverBefore_(content, wordStart) {
828853
/**
829854
* Return the FOAM class id of the receiver expression that precedes
830-
* `line[wordStart]`. Handles arbitrarily long chains:
831-
* • `foo.X.something.`
832-
* • `foo.X.Builder(x).`
833-
* • `foo.X.Builder(x).setA(true).setB(y).` ← chained builder
855+
* `content[wordStart]`. Operates on the FULL embedded content so
856+
* multi-line builder chains work:
857+
*
858+
* return new foo.X.Builder(x)
859+
* .setA(true)
860+
* .setB(y)
861+
* .setC(…) ← cursor on setC resolves receiver across lines
834862
*
835-
* Walks backward peeling alternating `word/dot` runs and balanced
836-
* `(…)` groups. Stops on whitespace, operators, or line start.
863+
* Walks backward peeling alternating word/dot runs and balanced
864+
* `(…)` groups; treats whitespace (including newlines) as part of
865+
* the chain if it's between a dot and the next token.
837866
*/
838-
if ( wordStart === 0 || line.charAt(wordStart - 1) !== '.' ) return null;
867+
if ( wordStart === 0 || content.charAt(wordStart - 1) !== '.' ) return null;
839868
var pos = wordStart - 2; // start before the trailing dot
840869
while ( pos >= 0 ) {
841-
var ch = line.charAt(pos);
870+
var ch = content.charAt(pos);
842871
if ( /[\w.$]/.test(ch) ) { pos--; continue; }
843872
if ( ch === ')' ) {
844873
var depth = 1;
845874
pos--;
846875
while ( pos >= 0 && depth > 0 ) {
847-
var c = line.charAt(pos);
876+
var c = content.charAt(pos);
848877
if ( c === ')' ) depth++;
849878
else if ( c === '(' ) depth--;
850879
if ( depth === 0 ) break;
@@ -854,15 +883,52 @@ foam.CLASS({
854883
pos--; // consume the '('
855884
continue;
856885
}
886+
if ( /\s/.test(ch) ) {
887+
// Whitespace (including newlines) is part of the chain only if it
888+
// sits between two chain elements — i.e. the next non-ws char
889+
// going backward is `.`, `)`, or `]`. This rejects `new foo.Bar`
890+
// (whitespace between `new` and the chain) while accepting
891+
// foo.Builder(x)
892+
// .setA(…) (ws before `.`)
893+
// and foo
894+
// .bar (ws before `.`)
895+
var skip = pos;
896+
while ( skip >= 0 && /\s/.test(content.charAt(skip)) ) skip--;
897+
if ( skip < 0 ) break;
898+
var prev = content.charAt(skip);
899+
if ( prev !== '.' && prev !== ')' && prev !== ']' ) break;
900+
pos = skip;
901+
continue;
902+
}
857903
break;
858904
}
859-
var start = pos + 1;
860-
while ( start < wordStart - 1 && line.charAt(start) === '.' ) start++;
861-
var receiverExpr = line.substring(start, wordStart - 1);
905+
var segStart = pos + 1;
906+
// Skip leading whitespace and leading dots.
907+
while ( segStart < wordStart - 1 && /[\s.]/.test(content.charAt(segStart)) ) segStart++;
908+
var receiverExpr = content.substring(segStart, wordStart - 1).replace(/\s+/g, '');
862909
if ( ! receiverExpr ) return null;
863910
return this.resolveReceiverType_(receiverExpr);
864911
},
865912

913+
function buildEnumValueHover_(classId, valueName) {
914+
/**
915+
* If classId is an enum and valueName is one of its values, build a
916+
* hover showing the value's label and ordinal.
917+
*/
918+
var values = this.index.getEnumValues(classId);
919+
if ( ! values || ! values.length ) return null;
920+
for ( var i = 0 ; i < values.length ; i++ ) {
921+
if ( values[i].name !== valueName ) continue;
922+
var v = values[i];
923+
var md = '```java\n' + classId + '.' + v.name + '\n```\n';
924+
md += '*enum value on `' + classId + '`*';
925+
if ( v.label ) md += '\n\nLabel: **' + v.label + '**';
926+
if ( v.ordinal != null ) md += '\n\nOrdinal: ' + v.ordinal;
927+
return md;
928+
}
929+
return null;
930+
},
931+
866932
function buildMemberHover_(classId, memberName) {
867933
/**
868934
* Build hover markdown for a member access on a known class. Matches
@@ -996,24 +1062,34 @@ foam.CLASS({
9961062
* or the builder's own type) this surfaces EVERY real setter
9971063
* the class actually declares, no hardcoded list.
9981064
*/
999-
var lines = text.split('\n');
1000-
var line = lines[position.line] || '';
1001-
var prefix = line.substring(0, position.character);
1002-
1003-
// Member access: `<receiver>.<partial>` — try registry-driven resolution.
1004-
var memberMatch = prefix.match(/([\w.$]+)\s*(?:\([^)]*\)\s*(?:\.\w+\s*\([^)]*\)\s*)*)?\.(\w*)$/);
1005-
if ( memberMatch ) {
1006-
var receiverExpr = memberMatch[1];
1007-
var partialName = memberMatch[2] || '';
1008-
var receiverType = this.resolveReceiverType_(receiverExpr);
1065+
// Operate on the FULL content up to the cursor so multi-line
1066+
// builder chains resolve correctly (each setter on its own line).
1067+
var cursorAbs = this.positionToOffset_(text, position);
1068+
var prefix = text.substring(0, cursorAbs);
1069+
1070+
// Partial word at cursor: letters only — we match an optional leading
1071+
// dot explicitly below.
1072+
var partialMatch = prefix.match(/\.(\w*)$/);
1073+
if ( partialMatch ) {
1074+
var partialName = partialMatch[1] || '';
1075+
var dotPos = cursorAbs - partialName.length - 1;
1076+
var receiverType = this.resolveReceiverBefore_(text, dotPos + 1);
10091077
if ( receiverType ) {
1078+
// Enum values take precedence — they're what you actually want
1079+
// when the receiver is an enum class.
1080+
var enumItems = this.enumValueCompletions_(receiverType, partialName);
1081+
if ( enumItems.length > 0 ) return { isIncomplete: false, items: enumItems };
1082+
10101083
var items = this.memberCompletions_(receiverType, partialName);
10111084
if ( items.length > 0 ) return { isIncomplete: false, items: items };
10121085
}
10131086
}
10141087

10151088
// Otherwise: dotted class-id prefix search.
1016-
var dotted = prefix.match(/([\w.$]+)$/);
1089+
var lines = text.split('\n');
1090+
var line = lines[position.line] || '';
1091+
var linePrefix = line.substring(0, position.character);
1092+
var dotted = linePrefix.match(/([\w.$]+)$/);
10171093
var partial = dotted ? dotted[1] : '';
10181094
var ids = this.index.getAllClassIds();
10191095
var out = [];
@@ -1054,6 +1130,29 @@ foam.CLASS({
10541130
return null;
10551131
},
10561132

1133+
function enumValueCompletions_(classId, partial) {
1134+
/**
1135+
* If classId is a FOAM enum, offer its VALUES as completions.
1136+
* Returns empty array for non-enums.
1137+
*/
1138+
var values = this.index.getEnumValues(classId);
1139+
if ( ! values || ! values.length ) return [];
1140+
var items = [];
1141+
var lower = partial ? partial.toLowerCase() : '';
1142+
for ( var i = 0 ; i < values.length ; i++ ) {
1143+
var v = values[i];
1144+
if ( lower && v.name.toLowerCase().indexOf(lower) === -1 ) continue;
1145+
items.push({
1146+
label: v.name,
1147+
kind: 20, // CompletionItemKind.EnumMember
1148+
detail: classId + (v.label ? ' — ' + v.label : ''),
1149+
insertText: v.name,
1150+
sortText: '!' + String(v.ordinal != null ? v.ordinal : i).padStart(5, '0')
1151+
});
1152+
}
1153+
return items;
1154+
},
1155+
10571156
function memberCompletions_(classId, partial) {
10581157
/**
10591158
* Build completion items for members of a FOAM class's Java surface:

tools/tests/testFoamLSP.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2799,6 +2799,71 @@ var vscodeJrl = JSON.parse(fs_.readFileSync(path_.join(__dirname, '../lsp/editor
27992799
test(!! vscodeJrl.repository['json-block-triple'] && !! vscodeJrl.repository['json-block-backtick'],
28002800
'VS Code foam-jrl grammar has JSON injections for client triple/backtick');
28012801

2802+
// === MULTI-LINE BUILDER CHAIN (real-world services.jrl shape) ===
2803+
section('Multi-line builder chain — hover + enum completion');
2804+
var mlsrc = [
2805+
'p({',
2806+
' "class":"foam.core.boot.CSpec",',
2807+
' "name":"transactionDAO",',
2808+
' "serviceScript": """',
2809+
' return new foam.dao.EasyDAO.Builder(x)',
2810+
' .setPm(true)',
2811+
' .setSeqNo(true)',
2812+
' .setJournalType(foam.dao.JournalType.SINGLE_JOURNAL)',
2813+
' .setOf(foam.lang.FObject.getOwnClassInfo())',
2814+
' .build();',
2815+
' """',
2816+
'})'
2817+
].join('\n');
2818+
2819+
// Hover on .setSeqNo — receiver is on line 4 (the return new ... Builder(x)),
2820+
// but .setSeqNo is on line 6. Walk-back must cross line boundaries.
2821+
var linesMl = mlsrc.split('\n');
2822+
var seqLine = 6; // .setSeqNo
2823+
var seqCol = linesMl[seqLine].indexOf('setSeqNo') + 2;
2824+
var seqH = jrlH3.handleHover(mlsrc, { line: seqLine, character: seqCol });
2825+
test(seqH && seqH.contents && seqH.contents.value && /setSeqNo|property|seqNo/i.test(seqH.contents.value),
2826+
'Multi-line: hover on .setSeqNo resolves to EasyDAO setter (receiver on prior line)');
2827+
2828+
// Hover on .setJournalType (line 7, two chained setters + Builder above)
2829+
var jtLine = 7;
2830+
var jtCol = linesMl[jtLine].indexOf('setJournalType') + 2;
2831+
var jtH = jrlH3.handleHover(mlsrc, { line: jtLine, character: jtCol });
2832+
test(jtH && jtH.contents && jtH.contents.value && /setJournalType|journalType|property/i.test(jtH.contents.value),
2833+
'Multi-line: hover on .setJournalType resolves to EasyDAO setter (chain with multiple prior setters)');
2834+
2835+
// Hover on SINGLE_JOURNAL — should be enum value hover.
2836+
var enumLine = 7;
2837+
var enumCol = linesMl[enumLine].indexOf('SINGLE_JOURNAL') + 2;
2838+
var enumH = jrlH3.handleHover(mlsrc, { line: enumLine, character: enumCol });
2839+
if ( index.classExists('foam.dao.JournalType') ) {
2840+
test(enumH && enumH.contents && enumH.contents.value && /JournalType\.SINGLE_JOURNAL|enum value/i.test(enumH.contents.value),
2841+
'Hover on foam.dao.JournalType.SINGLE_JOURNAL shows enum value info');
2842+
}
2843+
2844+
// Completion at `foam.dao.JournalType.` — must surface enum values FIRST,
2845+
// not all classes starting with foam.dao.JournalType.
2846+
if ( index.classExists('foam.dao.JournalType') ) {
2847+
var enumCompSrc = [
2848+
'p({',
2849+
' "serviceScript": """',
2850+
' return new foam.dao.EasyDAO.Builder(x).setJournalType(foam.dao.JournalType.',
2851+
' """',
2852+
'})'
2853+
].join('\n');
2854+
var enumCompLine = 2;
2855+
var enumCompCol = enumCompSrc.split('\n')[enumCompLine].length;
2856+
var enumComp = jrlH3.handleCompletion(enumCompSrc, { line: enumCompLine, character: enumCompCol });
2857+
var hasSingleJournal = enumComp.items.some(function(it) { return it.label === 'SINGLE_JOURNAL'; });
2858+
test(hasSingleJournal,
2859+
'Completion after `foam.dao.JournalType.` offers enum values (SINGLE_JOURNAL found: ' +
2860+
enumComp.items.slice(0, 4).map(function(i) { return i.label; }).join(',') + ')');
2861+
// First item must be an enum value (kind 20), not a class
2862+
var firstIsEnum = enumComp.items.length > 0 && enumComp.items[0].kind === 20;
2863+
test(firstIsEnum,
2864+
'First completion item is an enum value (kind=20), not a class');
2865+
}
2866+
28022867
// === CHAINED BUILDER SETTER HOVERS ===
28032868
section('Chained builder setter hovers — walk back through .a(x).b(y) chains');
28042869
var chained = [
@@ -2821,6 +2886,46 @@ var chainedLine = chained.split('\n')[4];
28212886
'Hover on .' + name + '( in chained builder — resolves to EasyDAO setter');
28222887
});
28232888

2889+
// === REAL services.jrl sanity check ===
2890+
section('Real services.jrl hover sanity');
2891+
var realJrlPath = require('path').resolve(__dirname, '../../../journals/services.jrl');
2892+
if ( require('fs').existsSync(realJrlPath) ) {
2893+
var realText = require('fs').readFileSync(realJrlPath, 'utf8');
2894+
var realLines = realText.split('\n');
2895+
2896+
// Find the first .setPm( occurrence and hover on it.
2897+
for ( var rl = 0 ; rl < realLines.length ; rl++ ) {
2898+
var m = realLines[rl].indexOf('.setPm(');
2899+
if ( m === -1 ) continue;
2900+
var h = jrlH3.handleHover(realText, { line: rl, character: m + 3 });
2901+
test(h && h.contents && h.contents.value && /setPm|pm/i.test(h.contents.value),
2902+
'Real services.jrl: hover on .setPm at line ' + rl + ' resolves (setPm or pm in output)');
2903+
break;
2904+
}
2905+
2906+
// First .setOf( across the entire file.
2907+
for ( var rl2 = 0 ; rl2 < realLines.length ; rl2++ ) {
2908+
var m2 = realLines[rl2].indexOf('.setOf(');
2909+
if ( m2 === -1 ) continue;
2910+
var h2 = jrlH3.handleHover(realText, { line: rl2, character: m2 + 3 });
2911+
test(h2 && h2.contents && h2.contents.value && /setOf|of /i.test(h2.contents.value),
2912+
'Real services.jrl: hover on .setOf at line ' + rl2 + ' resolves');
2913+
break;
2914+
}
2915+
2916+
// First `foam.dao.JournalType.SINGLE_JOURNAL` reference.
2917+
for ( var rl3 = 0 ; rl3 < realLines.length ; rl3++ ) {
2918+
var m3 = realLines[rl3].indexOf('JournalType.SINGLE_JOURNAL');
2919+
if ( m3 === -1 ) continue;
2920+
// Hover on SINGLE_JOURNAL
2921+
var sj = realLines[rl3].indexOf('SINGLE_JOURNAL');
2922+
var h3 = jrlH3.handleHover(realText, { line: rl3, character: sj + 2 });
2923+
test(h3 && h3.contents && h3.contents.value && /SINGLE_JOURNAL|enum value|ordinal/i.test(h3.contents.value),
2924+
'Real services.jrl: hover on SINGLE_JOURNAL enum value resolves');
2925+
break;
2926+
}
2927+
}
2928+
28242929
// === SUMMARY ===
28252930

28262931
section('SUMMARY');

0 commit comments

Comments
 (0)