aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <marussy@mit.bme.hu>2021-11-05 19:54:27 +0100
committerLibravatar GitHub <noreply@github.com>2021-11-05 19:54:27 +0100
commit8350a0634d1caf34826fb3ac41d5a892cf3ff1c9 (patch)
tree6769f681ff339e0796e2ca43525df3e58b6fc6db
parentMerge pull request #9 from kris7t/cm6-sonar-fixes (diff)
parentchore(web): implicit completion info in grammar (diff)
downloadrefinery-8350a0634d1caf34826fb3ac41d5a892cf3ff1c9.tar.gz
refinery-8350a0634d1caf34826fb3ac41d5a892cf3ff1c9.tar.zst
refinery-8350a0634d1caf34826fb3ac41d5a892cf3ff1c9.zip
Merge pull request #10 from kris7t/cm6-fixes
More CodeMirrror fixes
-rw-r--r--language-ide/src/main/java/tools/refinery/language/ide/ProblemIdeModule.java8
-rw-r--r--language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java77
-rw-r--r--language-model/src/main/java/tools/refinery/language/model/ProblemUtil.java19
-rw-r--r--language-web/package.json1
-rw-r--r--language-web/src/main/js/editor/EditorParent.ts9
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts2
-rw-r--r--language-web/src/main/js/language/problem.grammar14
-rw-r--r--language-web/src/main/js/language/props.ts7
-rw-r--r--language-web/src/main/js/xtext/ContentAssistService.ts98
-rw-r--r--language-web/yarn.lock2
-rw-r--r--language/src/main/java/tools/refinery/language/Problem.xtext6
-rw-r--r--language/src/main/java/tools/refinery/language/resource/ReferenceCounter.java51
-rw-r--r--language/src/main/java/tools/refinery/language/validation/ProblemValidator.java49
13 files changed, 302 insertions, 41 deletions
diff --git a/language-ide/src/main/java/tools/refinery/language/ide/ProblemIdeModule.java b/language-ide/src/main/java/tools/refinery/language/ide/ProblemIdeModule.java
index 3502c29f..51cecf06 100644
--- a/language-ide/src/main/java/tools/refinery/language/ide/ProblemIdeModule.java
+++ b/language-ide/src/main/java/tools/refinery/language/ide/ProblemIdeModule.java
@@ -4,9 +4,11 @@
4package tools.refinery.language.ide; 4package tools.refinery.language.ide;
5 5
6import org.eclipse.xtext.ide.editor.contentassist.IPrefixMatcher; 6import org.eclipse.xtext.ide.editor.contentassist.IPrefixMatcher;
7import org.eclipse.xtext.ide.editor.contentassist.IdeCrossrefProposalProvider;
7import org.eclipse.xtext.ide.editor.syntaxcoloring.ISemanticHighlightingCalculator; 8import org.eclipse.xtext.ide.editor.syntaxcoloring.ISemanticHighlightingCalculator;
8 9
9import tools.refinery.language.ide.contentassist.FuzzyMatcher; 10import tools.refinery.language.ide.contentassist.FuzzyMatcher;
11import tools.refinery.language.ide.contentassist.ProblemCrossrefProposalProvider;
10import tools.refinery.language.ide.syntaxcoloring.ProblemSemanticHighlightingCalculator; 12import tools.refinery.language.ide.syntaxcoloring.ProblemSemanticHighlightingCalculator;
11 13
12/** 14/**
@@ -16,9 +18,13 @@ public class ProblemIdeModule extends AbstractProblemIdeModule {
16 public Class<? extends ISemanticHighlightingCalculator> bindISemanticHighlightingCalculator() { 18 public Class<? extends ISemanticHighlightingCalculator> bindISemanticHighlightingCalculator() {
17 return ProblemSemanticHighlightingCalculator.class; 19 return ProblemSemanticHighlightingCalculator.class;
18 } 20 }
19 21
20 @Override 22 @Override
21 public Class<? extends IPrefixMatcher> bindIPrefixMatcher() { 23 public Class<? extends IPrefixMatcher> bindIPrefixMatcher() {
22 return FuzzyMatcher.class; 24 return FuzzyMatcher.class;
23 } 25 }
26
27 public Class<? extends IdeCrossrefProposalProvider> bindIdeCrossrefProposalProvider() {
28 return ProblemCrossrefProposalProvider.class;
29 }
24} 30}
diff --git a/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java b/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java
new file mode 100644
index 00000000..f828e836
--- /dev/null
+++ b/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java
@@ -0,0 +1,77 @@
1package tools.refinery.language.ide.contentassist;
2
3import java.util.Objects;
4
5import org.eclipse.emf.ecore.EObject;
6import org.eclipse.emf.ecore.util.EcoreUtil;
7import org.eclipse.xtext.CrossReference;
8import org.eclipse.xtext.GrammarUtil;
9import org.eclipse.xtext.ide.editor.contentassist.ContentAssistContext;
10import org.eclipse.xtext.ide.editor.contentassist.ContentAssistEntry;
11import org.eclipse.xtext.ide.editor.contentassist.IdeCrossrefProposalProvider;
12import org.eclipse.xtext.nodemodel.util.NodeModelUtils;
13import org.eclipse.xtext.resource.IEObjectDescription;
14
15import com.google.inject.Inject;
16
17import tools.refinery.language.model.ProblemUtil;
18import tools.refinery.language.model.problem.Problem;
19import tools.refinery.language.resource.ReferenceCounter;
20
21public class ProblemCrossrefProposalProvider extends IdeCrossrefProposalProvider {
22 @Inject
23 private ReferenceCounter referenceCounter;
24
25 @Override
26 protected ContentAssistEntry createProposal(IEObjectDescription candidate, CrossReference crossRef,
27 ContentAssistContext context) {
28 if (!shouldCreateProposal(candidate, crossRef, context)) {
29 return null;
30 }
31 return super.createProposal(candidate, crossRef, context);
32 }
33
34 protected boolean shouldCreateProposal(IEObjectDescription candidate, CrossReference crossRef,
35 ContentAssistContext context) {
36 var rootModel = context.getRootModel();
37 var eObjectOrProxy = candidate.getEObjectOrProxy();
38 if (!Objects.equals(rootModel.eResource(), eObjectOrProxy.eResource())) {
39 return true;
40 }
41 var currentValue = getCurrentValue(crossRef, context);
42 if (currentValue == null) {
43 return true;
44 }
45 var eObject = EcoreUtil.resolve(eObjectOrProxy, rootModel);
46 if (!Objects.equals(currentValue, eObject)) {
47 return true;
48 }
49 if (!ProblemUtil.isImplicit(eObject)) {
50 return true;
51 }
52 if (rootModel instanceof Problem problem) {
53 return referenceCounter.countReferences(problem, eObject) >= 2;
54 }
55 return true;
56 }
57
58 protected EObject getCurrentValue(CrossReference crossRef, ContentAssistContext context) {
59 var value = getCurrentValue(crossRef, context.getCurrentModel());
60 if (value != null) {
61 return value;
62 }
63 var currentNodeSemanticObject = NodeModelUtils.findActualSemanticObjectFor(context.getCurrentNode());
64 return getCurrentValue(crossRef, currentNodeSemanticObject);
65 }
66
67 protected EObject getCurrentValue(CrossReference crossRef, EObject context) {
68 if (context == null) {
69 return null;
70 }
71 var eReference = GrammarUtil.getReference(crossRef, context.eClass());
72 if (eReference == null || eReference.isMany()) {
73 return null;
74 }
75 return (EObject) context.eGet(eReference);
76 }
77}
diff --git a/language-model/src/main/java/tools/refinery/language/model/ProblemUtil.java b/language-model/src/main/java/tools/refinery/language/model/ProblemUtil.java
index b6b199f8..5f8641bf 100644
--- a/language-model/src/main/java/tools/refinery/language/model/ProblemUtil.java
+++ b/language-model/src/main/java/tools/refinery/language/model/ProblemUtil.java
@@ -12,6 +12,7 @@ import org.eclipse.emf.ecore.EObject;
12import org.eclipse.emf.ecore.resource.Resource; 12import org.eclipse.emf.ecore.resource.Resource;
13 13
14import tools.refinery.language.model.problem.ClassDeclaration; 14import tools.refinery.language.model.problem.ClassDeclaration;
15import tools.refinery.language.model.problem.ImplicitVariable;
15import tools.refinery.language.model.problem.Node; 16import tools.refinery.language.model.problem.Node;
16import tools.refinery.language.model.problem.Problem; 17import tools.refinery.language.model.problem.Problem;
17import tools.refinery.language.model.problem.ProblemPackage; 18import tools.refinery.language.model.problem.ProblemPackage;
@@ -33,6 +34,24 @@ public final class ProblemUtil {
33 public static boolean isSingletonVariable(Variable variable) { 34 public static boolean isSingletonVariable(Variable variable) {
34 return variable.eContainingFeature() == ProblemPackage.Literals.VARIABLE_OR_NODE_ARGUMENT__SINGLETON_VARIABLE; 35 return variable.eContainingFeature() == ProblemPackage.Literals.VARIABLE_OR_NODE_ARGUMENT__SINGLETON_VARIABLE;
35 } 36 }
37
38 public static boolean isImplicitVariable(Variable variable) {
39 return variable instanceof ImplicitVariable;
40 }
41
42 public static boolean isImplicitNode(Node node) {
43 return node.eContainingFeature() == ProblemPackage.Literals.PROBLEM__NODES;
44 }
45
46 public static boolean isImplicit(EObject eObject) {
47 if (eObject instanceof Node node) {
48 return isImplicitNode(node);
49 } else if (eObject instanceof Variable variable) {
50 return isImplicitVariable(variable);
51 } else {
52 return false;
53 }
54 }
36 55
37 public static boolean isUniqueNode(Node node) { 56 public static boolean isUniqueNode(Node node) {
38 var containingFeature = node.eContainingFeature(); 57 var containingFeature = node.eContainingFeature();
diff --git a/language-web/package.json b/language-web/package.json
index cb860c5f..3362a47a 100644
--- a/language-web/package.json
+++ b/language-web/package.json
@@ -85,6 +85,7 @@
85 "escape-string-regexp": "^5.0.0", 85 "escape-string-regexp": "^5.0.0",
86 "@fontsource/jetbrains-mono": "^4.5.0", 86 "@fontsource/jetbrains-mono": "^4.5.0",
87 "@fontsource/roboto": "^4.5.1", 87 "@fontsource/roboto": "^4.5.1",
88 "@lezer/common": "^0.15.7",
88 "@lezer/lr": "^0.15.4", 89 "@lezer/lr": "^0.15.4",
89 "loglevel": "^1.7.1", 90 "loglevel": "^1.7.1",
90 "loglevel-plugin-prefix": "^0.8.4", 91 "loglevel-plugin-prefix": "^0.8.4",
diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts
index ee1323f6..2d74b863 100644
--- a/language-web/src/main/js/editor/EditorParent.ts
+++ b/language-web/src/main/js/editor/EditorParent.ts
@@ -34,6 +34,9 @@ export const EditorParent = styled('div')(({ theme }) => {
34 '&, .cm-editor': { 34 '&, .cm-editor': {
35 height: '100%', 35 height: '100%',
36 }, 36 },
37 '.cm-content': {
38 padding: 0,
39 },
37 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { 40 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': {
38 fontSize: 16, 41 fontSize: 16,
39 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', 42 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
@@ -46,7 +49,7 @@ export const EditorParent = styled('div')(({ theme }) => {
46 color: theme.palette.text.secondary, 49 color: theme.palette.text.secondary,
47 }, 50 },
48 '.cm-gutters': { 51 '.cm-gutters': {
49 background: theme.palette.background.default, 52 background: 'rgba(255, 255, 255, 0.1)',
50 color: theme.palette.text.disabled, 53 color: theme.palette.text.disabled,
51 border: 'none', 54 border: 'none',
52 }, 55 },
@@ -57,7 +60,9 @@ export const EditorParent = styled('div')(({ theme }) => {
57 background: 'rgba(0, 0, 0, 0.3)', 60 background: 'rgba(0, 0, 0, 0.3)',
58 }, 61 },
59 '.cm-activeLineGutter': { 62 '.cm-activeLineGutter': {
60 background: 'rgba(0, 0, 0, 0.3)', 63 background: 'transparent',
64 },
65 '.cm-lineNumbers .cm-activeLineGutter': {
61 color: theme.palette.text.primary, 66 color: theme.palette.text.primary,
62 }, 67 },
63 '.cm-cursor, .cm-cursor-primary': { 68 '.cm-cursor, .cm-cursor-primary': {
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts
index ba31efcb..059233f4 100644
--- a/language-web/src/main/js/editor/EditorStore.ts
+++ b/language-web/src/main/js/editor/EditorStore.ts
@@ -112,8 +112,8 @@ export class EditorStore {
112 }), 112 }),
113 semanticHighlighting, 113 semanticHighlighting,
114 // We add the gutters to `extensions` in the order we want them to appear. 114 // We add the gutters to `extensions` in the order we want them to appear.
115 foldGutter(),
116 lineNumbers(), 115 lineNumbers(),
116 foldGutter(),
117 keymap.of([ 117 keymap.of([
118 ...closeBracketsKeymap, 118 ...closeBracketsKeymap,
119 ...commentKeymap, 119 ...commentKeymap,
diff --git a/language-web/src/main/js/language/problem.grammar b/language-web/src/main/js/language/problem.grammar
index c242a4ba..8e39243f 100644
--- a/language-web/src/main/js/language/problem.grammar
+++ b/language-web/src/main/js/language/problem.grammar
@@ -1,3 +1,7 @@
1@detectDelim
2
3@external prop implicitCompletion from '../../../../src/main/js/language/props.ts'
4
1@top Problem { statement* } 5@top Problem { statement* }
2 6
3statement { 7statement {
@@ -24,7 +28,7 @@ statement {
24 RuleBody { ":" sep<OrOp, Conjunction> "~>" sep<OrOp, Action> "." } 28 RuleBody { ":" sep<OrOp, Conjunction> "~>" sep<OrOp, Action> "." }
25 } | 29 } |
26 Assertion { 30 Assertion {
27 ckw<"default">? (NotOp | UnknownOp)? RelationName 31 kw<"default">? (NotOp | UnknownOp)? RelationName
28 ParameterList<AssertionArgument> (":" LogicValue)? "." 32 ParameterList<AssertionArgument> (":" LogicValue)? "."
29 } | 33 } |
30 NodeValueAssertion { 34 NodeValueAssertion {
@@ -34,7 +38,7 @@ statement {
34 ckw<"unique"> sep<",", UniqueNodeName> "." 38 ckw<"unique"> sep<",", UniqueNodeName> "."
35 } | 39 } |
36 ScopeDeclaration { 40 ScopeDeclaration {
37 ckw<"scope"> sep<",", ScopeElement> "." 41 kw<"scope"> sep<",", ScopeElement> "."
38 } 42 }
39} 43}
40 44
@@ -89,11 +93,11 @@ VariableName { QualifiedName }
89 93
90NodeName { QualifiedName } 94NodeName { QualifiedName }
91 95
92QualifiedName { identifier ("::" identifier)* } 96QualifiedName[implicitCompletion=true] { identifier ("::" identifier)* }
93 97
94kw<term> { @specialize[@name={term}]<identifier, term> } 98kw<term> { @specialize[@name={term},implicitCompletion=true]<identifier, term> }
95 99
96ckw<term> { @extend[@name={term}]<identifier, term> } 100ckw<term> { @extend[@name={term},implicitCompletion=true]<identifier, term> }
97 101
98ParameterList<content> { "(" sep<",", content> ")" } 102ParameterList<content> { "(" sep<",", content> ")" }
99 103
diff --git a/language-web/src/main/js/language/props.ts b/language-web/src/main/js/language/props.ts
new file mode 100644
index 00000000..8e488bf5
--- /dev/null
+++ b/language-web/src/main/js/language/props.ts
@@ -0,0 +1,7 @@
1import { NodeProp } from '@lezer/common';
2
3export const implicitCompletion = new NodeProp({
4 deserialize(s: string) {
5 return s === 'true';
6 },
7});
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts
index f085c5b1..aa9a80b0 100644
--- a/language-web/src/main/js/xtext/ContentAssistService.ts
+++ b/language-web/src/main/js/xtext/ContentAssistService.ts
@@ -3,9 +3,11 @@ import type {
3 CompletionContext, 3 CompletionContext,
4 CompletionResult, 4 CompletionResult,
5} from '@codemirror/autocomplete'; 5} from '@codemirror/autocomplete';
6import { syntaxTree } from '@codemirror/language';
6import type { Transaction } from '@codemirror/state'; 7import type { Transaction } from '@codemirror/state';
7import escapeStringRegexp from 'escape-string-regexp'; 8import escapeStringRegexp from 'escape-string-regexp';
8 9
10import { implicitCompletion } from '../language/props';
9import type { UpdateService } from './UpdateService'; 11import type { UpdateService } from './UpdateService';
10import { getLogger } from '../utils/logger'; 12import { getLogger } from '../utils/logger';
11import type { IContentAssistEntry } from './xtextServiceResults'; 13import type { IContentAssistEntry } from './xtextServiceResults';
@@ -14,12 +16,57 @@ const PROPOSALS_LIMIT = 1000;
14 16
15const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; 17const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
16 18
17const HIGH_PRIORITY_KEYWORDS = ['<->']; 19const HIGH_PRIORITY_KEYWORDS = ['<->', '~>'];
18
19const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g;
20 20
21const log = getLogger('xtext.ContentAssistService'); 21const log = getLogger('xtext.ContentAssistService');
22 22
23interface IFoundToken {
24 from: number;
25
26 to: number;
27
28 implicitCompletion: boolean;
29
30 text: string;
31}
32
33function findToken({ pos, state }: CompletionContext): IFoundToken | null {
34 const token = syntaxTree(state).resolveInner(pos, -1);
35 if (token === null) {
36 return null;
37 }
38 if (token.firstChild !== null) {
39 // We only autocomplete terminal nodes. If the current node is nonterminal,
40 // returning `null` makes us autocomplete with the empty prefix instead.
41 return null;
42 }
43 return {
44 from: token.from,
45 to: token.to,
46 implicitCompletion: token.type.prop(implicitCompletion) || false,
47 text: state.sliceDoc(token.from, token.to),
48 };
49}
50
51function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean {
52 return token !== null
53 && token.implicitCompletion
54 && context.pos - token.from >= 2;
55}
56
57function computeSpan(prefix: string, entryCount: number): RegExp {
58 const escapedPrefix = escapeStringRegexp(prefix);
59 if (entryCount < PROPOSALS_LIMIT) {
60 // Proposals with the current prefix fit the proposals limit.
61 // We can filter client side as long as the current prefix is preserved.
62 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
63 }
64 // The current prefix overflows the proposals limits,
65 // so we have to fetch the completions again on the next keypress.
66 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
67 return new RegExp(`^${escapedPrefix}$`);
68}
69
23function createCompletion(entry: IContentAssistEntry): Completion { 70function createCompletion(entry: IContentAssistEntry): Completion {
24 let boost; 71 let boost;
25 switch (entry.kind) { 72 switch (entry.kind) {
@@ -33,7 +80,7 @@ function createCompletion(entry: IContentAssistEntry): Completion {
33 break; 80 break;
34 default: { 81 default: {
35 // Penalize qualified names (vs available unqualified names). 82 // Penalize qualified names (vs available unqualified names).
36 const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0; 83 const extraSegments = entry.proposal.match(/::/g)?.length || 0;
37 boost = Math.max(-5 * extraSegments, -50); 84 boost = Math.max(-5 * extraSegments, -50);
38 } 85 }
39 break; 86 break;
@@ -47,19 +94,6 @@ function createCompletion(entry: IContentAssistEntry): Completion {
47 }; 94 };
48} 95}
49 96
50function computeSpan(prefix: string, entryCount: number) {
51 const escapedPrefix = escapeStringRegexp(prefix);
52 if (entryCount < PROPOSALS_LIMIT) {
53 // Proposals with the current prefix fit the proposals limit.
54 // We can filter client side as long as the current prefix is preserved.
55 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
56 }
57 // The current prefix overflows the proposals limits,
58 // so we have to fetch the completions again on the next keypress.
59 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
60 return new RegExp(`^${escapedPrefix}$`);
61}
62
63export class ContentAssistService { 97export class ContentAssistService {
64 private readonly updateService: UpdateService; 98 private readonly updateService: UpdateService;
65 99
@@ -76,16 +110,16 @@ export class ContentAssistService {
76 } 110 }
77 111
78 async contentAssist(context: CompletionContext): Promise<CompletionResult> { 112 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
79 const tokenBefore = context.tokenBefore(['QualifiedName']); 113 const tokenBefore = findToken(context);
114 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) {
115 return {
116 from: context.pos,
117 options: [],
118 };
119 }
80 let range: { from: number, to: number }; 120 let range: { from: number, to: number };
81 let prefix = ''; 121 let prefix = '';
82 if (tokenBefore === null) { 122 if (tokenBefore === null) {
83 if (!context.explicit) {
84 return {
85 from: context.pos,
86 options: [],
87 };
88 }
89 range = { 123 range = {
90 from: context.pos, 124 from: context.pos,
91 to: context.pos, 125 to: context.pos,
@@ -124,7 +158,12 @@ export class ContentAssistService {
124 } 158 }
125 const options: Completion[] = []; 159 const options: Completion[] = [];
126 entries.forEach((entry) => { 160 entries.forEach((entry) => {
127 options.push(createCompletion(entry)); 161 if (prefix === entry.prefix) {
162 // Xtext will generate completions that do not complete the current token,
163 // e.g., `(` after trying to complete an indetifier,
164 // but we ignore those, since CodeMirror won't filter for them anyways.
165 options.push(createCompletion(entry));
166 }
128 }); 167 });
129 log.debug('Fetched', options.length, 'completions from server'); 168 log.debug('Fetched', options.length, 'completions from server');
130 this.lastCompletion = { 169 this.lastCompletion = {
@@ -137,7 +176,7 @@ export class ContentAssistService {
137 176
138 private shouldReturnCachedCompletion( 177 private shouldReturnCachedCompletion(
139 token: { from: number, to: number, text: string } | null, 178 token: { from: number, to: number, text: string } | null,
140 ) { 179 ): boolean {
141 if (token === null || this.lastCompletion === null) { 180 if (token === null || this.lastCompletion === null) {
142 return false; 181 return false;
143 } 182 }
@@ -147,10 +186,13 @@ export class ContentAssistService {
147 return true; 186 return true;
148 } 187 }
149 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); 188 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
150 return from >= transformedFrom && to <= transformedTo && span && span.exec(text); 189 return from >= transformedFrom
190 && to <= transformedTo
191 && typeof span !== 'undefined'
192 && span.exec(text) !== null;
151 } 193 }
152 194
153 private shouldInvalidateCachedCompletion(transaction: Transaction) { 195 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
154 if (!transaction.docChanged || this.lastCompletion === null) { 196 if (!transaction.docChanged || this.lastCompletion === null) {
155 return false; 197 return false;
156 } 198 }
diff --git a/language-web/yarn.lock b/language-web/yarn.lock
index e05f4fa6..06b30508 100644
--- a/language-web/yarn.lock
+++ b/language-web/yarn.lock
@@ -1476,7 +1476,7 @@
1476 resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" 1476 resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
1477 integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== 1477 integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
1478 1478
1479"@lezer/common@^0.15.0", "@lezer/common@^0.15.5": 1479"@lezer/common@^0.15.0", "@lezer/common@^0.15.5", "@lezer/common@^0.15.7":
1480 version "0.15.7" 1480 version "0.15.7"
1481 resolved "https://registry.yarnpkg.com/@lezer/common/-/common-0.15.7.tgz#8b445dae9777f689783132cf490770ece3c03d7b" 1481 resolved "https://registry.yarnpkg.com/@lezer/common/-/common-0.15.7.tgz#8b445dae9777f689783132cf490770ece3c03d7b"
1482 integrity sha512-Rw8TDJnBzZnkyzIXs1Tmmd241FrBLJBj8gkdy3y0joGFb8Z4I/joKEsR+gv1pb13o1TMsZxm3fmP+d/wPt2CTQ== 1482 integrity sha512-Rw8TDJnBzZnkyzIXs1Tmmd241FrBLJBj8gkdy3y0joGFb8Z4I/joKEsR+gv1pb13o1TMsZxm3fmP+d/wPt2CTQ==
diff --git a/language/src/main/java/tools/refinery/language/Problem.xtext b/language/src/main/java/tools/refinery/language/Problem.xtext
index 6f6a8588..0fa47d63 100644
--- a/language/src/main/java/tools/refinery/language/Problem.xtext
+++ b/language/src/main/java/tools/refinery/language/Problem.xtext
@@ -154,7 +154,7 @@ ScopeDeclaration:
154 "scope" typeScopes+=TypeScope ("," typeScopes+=TypeScope)* "."; 154 "scope" typeScopes+=TypeScope ("," typeScopes+=TypeScope)* ".";
155 155
156TypeScope: 156TypeScope:
157 targetType=[ClassDeclaration] 157 targetType=[ClassDeclaration|QualifiedName]
158 (increment?="+=" | "=") 158 (increment?="+=" | "=")
159 multiplicity=DefiniteMultiplicity; 159 multiplicity=DefiniteMultiplicity;
160 160
@@ -183,8 +183,8 @@ QualifiedName hidden():
183 Identifier ("::" Identifier)*; 183 Identifier ("::" Identifier)*;
184 184
185Identifier: 185Identifier:
186 ID | "true" | "false" | "unknown" | "error" | "class" | "abstract" | "extends" | "enum" | "pred" | "scope" | 186 ID | "true" | "false" | "unknown" | "error" | "class" | "abstract" | "extends" | "enum" | "pred" |
187 "unique" | "default" | "problem" | "new" | "delete"; 187 "unique" | "problem" | "new" | "delete";
188 188
189Integer returns ecore::EInt hidden(): 189Integer returns ecore::EInt hidden():
190 "-"? INT; 190 "-"? INT;
diff --git a/language/src/main/java/tools/refinery/language/resource/ReferenceCounter.java b/language/src/main/java/tools/refinery/language/resource/ReferenceCounter.java
new file mode 100644
index 00000000..7525dfc6
--- /dev/null
+++ b/language/src/main/java/tools/refinery/language/resource/ReferenceCounter.java
@@ -0,0 +1,51 @@
1package tools.refinery.language.resource;
2
3import java.util.HashMap;
4import java.util.Map;
5
6import org.eclipse.emf.ecore.EObject;
7import org.eclipse.xtext.util.IResourceScopeCache;
8
9import com.google.inject.Inject;
10import com.google.inject.Singleton;
11
12import tools.refinery.language.model.problem.Problem;
13
14@Singleton
15public class ReferenceCounter {
16 @Inject
17 private IResourceScopeCache cache;
18
19 public int countReferences(Problem problem, EObject eObject) {
20 var count = getReferenceCounts(problem).get(eObject);
21 if (count == null) {
22 return 0;
23 }
24 return count;
25 }
26
27 protected Map<EObject, Integer> getReferenceCounts(Problem problem) {
28 var resource = problem.eResource();
29 if (resource == null) {
30 return doGetReferenceCounts(problem);
31 }
32 return cache.get(problem, resource, () -> doGetReferenceCounts(problem));
33 }
34
35 protected Map<EObject, Integer> doGetReferenceCounts(Problem problem) {
36 var map = new HashMap<EObject, Integer>();
37 countCrossReferences(problem, map);
38 var iterator = problem.eAllContents();
39 while (iterator.hasNext()) {
40 var eObject = iterator.next();
41 countCrossReferences(eObject, map);
42 }
43 return map;
44 }
45
46 protected void countCrossReferences(EObject eObject, Map<EObject, Integer> map) {
47 for (var referencedObject : eObject.eCrossReferences()) {
48 map.compute(referencedObject, (key, currentValue) -> currentValue == null ? 1 : currentValue + 1);
49 }
50 }
51}
diff --git a/language/src/main/java/tools/refinery/language/validation/ProblemValidator.java b/language/src/main/java/tools/refinery/language/validation/ProblemValidator.java
index f2378df6..dfd386f5 100644
--- a/language/src/main/java/tools/refinery/language/validation/ProblemValidator.java
+++ b/language/src/main/java/tools/refinery/language/validation/ProblemValidator.java
@@ -3,6 +3,19 @@
3 */ 3 */
4package tools.refinery.language.validation; 4package tools.refinery.language.validation;
5 5
6import org.eclipse.xtext.EcoreUtil2;
7import org.eclipse.xtext.validation.Check;
8
9import com.google.inject.Inject;
10
11import tools.refinery.language.model.ProblemUtil;
12import tools.refinery.language.model.problem.Node;
13import tools.refinery.language.model.problem.Problem;
14import tools.refinery.language.model.problem.ProblemPackage;
15import tools.refinery.language.model.problem.Variable;
16import tools.refinery.language.model.problem.VariableOrNodeArgument;
17import tools.refinery.language.resource.ReferenceCounter;
18
6/** 19/**
7 * This class contains custom validation rules. 20 * This class contains custom validation rules.
8 * 21 *
@@ -10,4 +23,40 @@ package tools.refinery.language.validation;
10 * https://www.eclipse.org/Xtext/documentation/303_runtime_concepts.html#validation 23 * https://www.eclipse.org/Xtext/documentation/303_runtime_concepts.html#validation
11 */ 24 */
12public class ProblemValidator extends AbstractProblemValidator { 25public class ProblemValidator extends AbstractProblemValidator {
26 private static final String ISSUE_PREFIX = "tools.refinery.language.validation.ProblemValidator.";
27
28 public static final String SINGLETON_VARIABLE_ISSUE = ISSUE_PREFIX + "SINGLETON_VARIABLE";
29
30 public static final String NON_UNIQUE_NODE_ISSUE = ISSUE_PREFIX + "NON_UNIQUE_NODE";
31
32 @Inject
33 private ReferenceCounter referenceCounter;
34
35 @Check
36 public void checkUniqueVariable(VariableOrNodeArgument argument) {
37 var variableOrNode = argument.getVariableOrNode();
38 if (variableOrNode instanceof Variable variable && ProblemUtil.isImplicitVariable(variable)
39 && !ProblemUtil.isSingletonVariable(variable)) {
40 var problem = EcoreUtil2.getContainerOfType(variable, Problem.class);
41 if (problem != null && referenceCounter.countReferences(problem, variable) <= 1) {
42 var name = variable.getName();
43 var message = "Variable '%s' has only a single reference. Add another reference or mark is as a singleton variable: '_%s'"
44 .formatted(name, name);
45 warning(message, argument, ProblemPackage.Literals.VARIABLE_OR_NODE_ARGUMENT__VARIABLE_OR_NODE,
46 INSIGNIFICANT_INDEX, SINGLETON_VARIABLE_ISSUE);
47 }
48 }
49 }
50
51 @Check
52 public void checkNonUniqueNode(VariableOrNodeArgument argument) {
53 var variableOrNode = argument.getVariableOrNode();
54 if (variableOrNode instanceof Node node && !ProblemUtil.isUniqueNode(node)) {
55 var name = node.getName();
56 var message = "Only unique nodes can be referenced in predicates. Mark '%s' as unique with the declaration 'unique %s.'"
57 .formatted(name, name);
58 error(message, argument, ProblemPackage.Literals.VARIABLE_OR_NODE_ARGUMENT__VARIABLE_OR_NODE,
59 INSIGNIFICANT_INDEX, NON_UNIQUE_NODE_ISSUE);
60 }
61 }
13} 62}