1

Merge branch 'develop'

This commit is contained in:
2026-04-17 03:26:01 +03:00
333 changed files with 30643 additions and 0 deletions

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
[*.json]
indent_size = 2

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
gradlew text eol=lf
gradlew.bat text eol=crlf
gradle/wrapper/gradle-wrapper.properties text eol=lf

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Gradle
.gradle/
build/
# Java
hs_err_pid*.log
# JetBrains IDEA
.idea/
!.idea/codeStyles/codeStyleConfig.xml
!.idea/codeStyles/Project.xml
!.idea/codeInsightSettings.xml
!.idea/.gitignore
*.iml

10
README.MD Normal file
View File

@@ -0,0 +1,10 @@
# Project Fluent
Это набор Java пакетов для использования [Fluent localization system](http://projectfluent.org/).
fluent-java состоит из следующих пакетов:
## fluent-syntax
Пакет [fluent-syntax](fluent-syntax) включает в себя синтаксический анализатор (parser), сериализатор (serializer),
и инструменты обхода, такие как Visitor. Данный пакет понадобится при работе над инструментами для Fluent в Java.

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
tasks.withType<Wrapper> {
gradleVersion = "8.14.4"
distributionType = Wrapper.DistributionType.BIN
}

31
fluent-syntax/README.MD Normal file
View File

@@ -0,0 +1,31 @@
# fluent-syntax
Чтение, запись и прочие преобразования файлов [Fluent](https://projectfluent.org/).
Этот пакет включает в себя синтаксический анализатор (parser), сериализатор (serializer),
и инструменты обхода, такие как Visitor. Данный пакет понадобится при работе над инструментами для Fluent в Java.
```java
import ru.di9.fluent.syntax.ast.Message;
import ru.di9.fluent.syntax.ast.Resource;
import ru.di9.fluent.syntax.parser.FluentParser;
import ru.di9.fluent.syntax.serializer.FluentSerializer;
public class App {
public static void main(String[] args) {
var parser = new FluentParser();
Resource resource = parser.parse("a-key = String to localize");
System.out.println(((Message) resource.getBody().get(0)).getId().getName());
// "a-key"
var serializer = new FluentSerializer();
System.out.println(serializer.serialize(resource));
// "a-key = String to localize"
System.out.println(serializer.serialize(resource.getBody().get(0)));
// "a-key = String to localize"
}
}
```

View File

@@ -0,0 +1,46 @@
plugins {
java
id("maven-publish")
}
group = "ru.di9.fluent"
version = "0.1.0"
java.toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
annotationProcessor(libs.lombok)
compileOnly(libs.lombok)
testImplementation(platform(libs.junit.platform))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.launcher)
testAnnotationProcessor(libs.lombok)
testCompileOnly(libs.lombok)
testImplementation(libs.assertj)
testImplementation(libs.gson)
testImplementation(libs.joor)
testImplementation(libs.jsonassert)
}
tasks.withType<Test> {
useJUnitPlatform()
}
publishing.publications {
create<MavenPublication>("mavenBinary") {
groupId = project.group.toString()
artifactId = project.name
version = project.version.toString()
from(components["java"])
}
}

View File

@@ -0,0 +1,32 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Annotation extends SyntaxNode {
private final List<Object> arguments = new ArrayList<>();
private final String code;
private final String message;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Annotation that = (Annotation) o;
return Objects.equals(code, that.code) &&
Objects.equals(message, that.message) &&
Objects.equals(arguments, that.arguments);
}
@Override
public int hashCode() {
return Objects.hash(code, message, arguments);
}
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Attribute extends SyntaxNode {
private final Identifier id;
private final Pattern value;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Attribute attribute = (Attribute) o;
return Objects.equals(id, attribute.id) &&
Objects.equals(value, attribute.value);
}
@Override
public int hashCode() {
return Objects.hash(id, value);
}
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public abstract class BaseComment extends Entry {
private final String content;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
BaseComment that = (BaseComment) o;
return Objects.equals(content, that.content);
}
@Override
public int hashCode() {
return Objects.hash(content);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public abstract class BaseNode {
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public interface CallArgument {
}

View File

@@ -0,0 +1,27 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Getter
public class CallArguments extends SyntaxNode {
private final List<Expression> positional = new ArrayList<>();
private final List<NamedArgument> named = new ArrayList<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
CallArguments that = (CallArguments) o;
return Objects.equals(positional, that.positional) && Objects.equals(named, that.named);
}
@Override
public int hashCode() {
return Objects.hash(positional, named);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.ast;
public class Comment extends BaseComment {
public Comment(String content) {
super(content);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public class Entry extends TopLevel {
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public abstract class Expression extends SyntaxNode implements CallArgument, InsidePlaceable {
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class FunctionReference extends Expression {
private final Identifier id;
private final CallArguments arguments;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
FunctionReference that = (FunctionReference) o;
return Objects.equals(id, that.id) &&
Objects.equals(arguments, that.arguments);
}
@Override
public int hashCode() {
return Objects.hash(id, arguments);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.ast;
public class GroupComment extends BaseComment {
public GroupComment(String content) {
super(content);
}
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
@ToString(of = {"name"})
public class Identifier extends SyntaxNode implements VariantKey {
private final String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Identifier that = (Identifier) o;
return Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public interface InsidePlaceable {
}

View File

@@ -0,0 +1,34 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Junk extends TopLevel {
private final List<Annotation> annotations = new ArrayList<>();
private final String content;
public void addAnnotation(Annotation annotation) {
this.getAnnotations().add(annotation);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Junk junk = (Junk) o;
return Objects.equals(content, junk.content) &&
Objects.equals(annotations, junk.annotations);
}
@Override
public int hashCode() {
return Objects.hash(content, annotations);
}
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public abstract class Literal extends Expression {
private final String value;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Literal literal = (Literal) o;
return Objects.equals(value, literal.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}

View File

@@ -0,0 +1,50 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@RequiredArgsConstructor
public class Message extends Entry {
@Getter
private final List<Attribute> attributes = new ArrayList<>();
@Getter
private final Identifier id;
private final Pattern value;
@Setter
private Comment comment;
public Optional<Comment> getComment() {
return Optional.ofNullable(comment);
}
public Optional<Pattern> getValue() {
return Optional.ofNullable(value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Message message = (Message) o;
return Objects.equals(id, message.id) &&
Objects.equals(value, message.value) &&
Objects.equals(attributes, message.attributes) &&
Objects.equals(comment, message.comment);
}
@Override
public int hashCode() {
return Objects.hash(id, value, attributes, comment);
}
}

View File

@@ -0,0 +1,35 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
import java.util.Optional;
@RequiredArgsConstructor
public class MessageReference extends Expression {
@Getter
private final Identifier id;
private final Identifier attribute;
public Optional<Identifier> getAttribute() {
return Optional.ofNullable(attribute);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
MessageReference that = (MessageReference) o;
return Objects.equals(id, that.id) &&
Objects.equals(attribute, that.attribute);
}
@Override
public int hashCode() {
return Objects.hash(id, attribute);
}
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class NamedArgument extends SyntaxNode implements CallArgument {
private final Identifier name;
private final Literal value;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
NamedArgument that = (NamedArgument) o;
return Objects.equals(name, that.name) &&
Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(name, value);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.ast;
public class NumberLiteral extends Literal implements VariantKey {
public NumberLiteral(String value) {
super(value);
}
}

View File

@@ -0,0 +1,39 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
@Getter
public class Pattern extends SyntaxNode {
private final List<PatternElement> elements;
public Pattern() {
this.elements = new ArrayList<>();
}
public Pattern(PatternElement... elements) {
this.elements = new ArrayList<>(Arrays.asList(elements));
}
public Pattern(List<PatternElement> elements) {
this.elements = elements;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
var that = (Pattern) o;
return Objects.equals(this.elements, that.elements);
}
@Override
public int hashCode() {
return Objects.hash(elements);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public abstract class PatternElement extends SyntaxNode {
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Placeable extends PatternElement implements InsidePlaceable {
private final InsidePlaceable expression;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Placeable placeable = (Placeable) o;
return Objects.equals(expression, placeable.expression);
}
@Override
public int hashCode() {
return Objects.hash(expression);
}
}

View File

@@ -0,0 +1,30 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Getter
public class Resource extends SyntaxNode {
private final List<TopLevel> body = new ArrayList<>();
public Resource(List<TopLevel> children) {
this.body.addAll(children);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Resource resource = (Resource) o;
return Objects.equals(body, resource.body);
}
@Override
public int hashCode() {
return Objects.hash(body);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.ast;
public class ResourceComment extends BaseComment {
public ResourceComment(String content) {
super(content);
}
}

View File

@@ -0,0 +1,29 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class SelectExpression extends Expression {
private final Expression selector;
private final List<Variant> variants;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
SelectExpression that = (SelectExpression) o;
return Objects.equals(selector, that.selector) &&
Objects.equals(variants, that.variants);
}
@Override
public int hashCode() {
return Objects.hash(selector, variants);
}
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.util.Objects;
@AllArgsConstructor
@Getter
@Setter
public class Span extends BaseNode {
private int start;
private int end;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Span span = (Span) o;
return start == span.start && end == span.end;
}
@Override
public int hashCode() {
return Objects.hash(start, end);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.ast;
public class StringLiteral extends Literal {
public StringLiteral(String value) {
super(value);
}
}

View File

@@ -0,0 +1,29 @@
package ru.di9.fluent.syntax.ast;
import java.util.Objects;
import java.util.Optional;
public abstract class SyntaxNode extends BaseNode {
private Span span;
public Optional<Span> getSpan() {
return Optional.ofNullable(span);
}
public void addSpan(int start, int end) {
span = new Span(start, end);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SyntaxNode that = (SyntaxNode) o;
return Objects.equals(span, that.span);
}
@Override
public int hashCode() {
return Objects.hash(span);
}
}

View File

@@ -0,0 +1,44 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@RequiredArgsConstructor
public class Term extends Entry {
@Getter
private final List<Attribute> attributes = new ArrayList<>();
@Getter
private final Identifier id;
@Getter
private final Pattern value;
@Setter
private Comment comment;
public Optional<Comment> getComment() {
return Optional.ofNullable(comment);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Term term = (Term) o;
return Objects.equals(id, term.id) && Objects.equals(value, term.value) && Objects.equals(attributes, term.attributes) && Objects.equals(comment, term.comment);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), id, value, attributes, comment);
}
}

View File

@@ -0,0 +1,42 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
import java.util.Optional;
@RequiredArgsConstructor
public class TermReference extends Expression {
@Getter
private final Identifier id;
private final Identifier attribute;
private final CallArguments arguments;
public Optional<Identifier> getAttribute() {
return Optional.ofNullable(attribute);
}
public Optional<CallArguments> getArguments() {
return Optional.ofNullable(arguments);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
TermReference that = (TermReference) o;
return Objects.equals(id, that.id) &&
Objects.equals(attribute, that.attribute) &&
Objects.equals(arguments, that.arguments);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), id, attribute, arguments);
}
}

View File

@@ -0,0 +1,28 @@
package ru.di9.fluent.syntax.ast;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.util.Objects;
@AllArgsConstructor
@Getter
@Setter
public class TextElement extends PatternElement {
private String value;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
TextElement that = (TextElement) o;
return Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), value);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public abstract class TopLevel extends SyntaxNode {
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class VariableReference extends Expression {
private final Identifier id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
VariableReference that = (VariableReference) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), id);
}
}

View File

@@ -0,0 +1,30 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Variant extends SyntaxNode {
private final VariantKey key;
private final Pattern value;
private final boolean isDefault;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Variant variant = (Variant) o;
return Objects.equals(key, variant.key) &&
Objects.equals(value, variant.value) &&
Objects.equals(isDefault, variant.isDefault);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), key, value, isDefault);
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.syntax.ast;
public interface VariantKey {
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.syntax.ast;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Objects;
@RequiredArgsConstructor
@Getter
public class Whitespace extends TopLevel {
private final String content;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Whitespace that = (Whitespace) o;
return Objects.equals(content, that.content);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), content);
}
}

View File

@@ -0,0 +1,874 @@
package ru.di9.fluent.syntax.parser;
import ru.di9.fluent.syntax.ast.*;
import java.util.*;
import java.util.function.Predicate;
import static ru.di9.fluent.syntax.utils.StringUtils.inRange_09;
import static ru.di9.fluent.syntax.parser.FluentStream.*;
import static ru.di9.fluent.syntax.parser.ParseException.ErrorCode.*;
public class FluentParser {
public boolean withSpans = false;
public boolean withJunkAnnotations = true;
public Resource parse(String source) {
var ps = new FluentStream(source);
int spanStart = ps.index;
List<TopLevel> entries = new ArrayList<>();
Comment lastComment = null;
String blankLines = ps.skipBlankBlock();
if (!blankLines.isEmpty()) {
entries.add(new Whitespace(blankLines));
}
while (ps.currentChar().isPresent()) {
TopLevel entry = getEntryOrJunk(ps);
blankLines = ps.skipBlankBlock();
// Regular Comments require special logic. Comments may be attached to
// Messages or Terms if they are followed immediately by them. However
// they should parse as standalone when they're followed by Junk.
// Consequently, we only attach Comments once we know that the Message
// or the Term parsed successfully.
if (entry instanceof Comment comment && blankLines.isEmpty() && ps.currentChar().isPresent()) {
// Stash the comment and decide what to do with it in the next pass.
lastComment = comment;
continue;
}
if (lastComment != null) {
if (entry instanceof Message message) {
message.setComment(lastComment);
if (withSpans && message.getSpan().isPresent() && lastComment.getSpan().isPresent()) {
message.getSpan().get().setStart(lastComment.getSpan().get().getStart());
}
} else if (entry instanceof Term term) {
term.setComment(lastComment);
if (withSpans && term.getSpan().isPresent() && lastComment.getSpan().isPresent()) {
term.getSpan().get().setStart(lastComment.getSpan().get().getStart());
}
} else {
entries.add(lastComment);
}
// In either case, the stashed comment has been dealt with; clear it.
lastComment = null;
}
// No special logic for other types of entries.
entries.add(entry);
if (!blankLines.isEmpty()) {
entries.add(new Whitespace(blankLines));
}
}
var resource = new Resource(entries);
if (withSpans) {
resource.addSpan(spanStart, ps.index);
}
return resource;
}
/// PRIVATE ////////////////////////////////////////////////////////////////////////////////////////////////////////
private TopLevel getEntryOrJunk(FluentStream ps) {
int entryStartPos = ps.index;
try {
Entry entry = getEntry(ps);
ps.expectLineEnd();
return entry;
} catch (ParseException e) {
int errorIndex = ps.index;
ps.skipToNextEntryStart(entryStartPos);
int nextEntryStart = ps.index;
if (nextEntryStart < errorIndex) {
// The position of the error must be inside of the Junk's span.
errorIndex = nextEntryStart;
}
// Create a Junk instance
String slice = ps.string.substring(entryStartPos, nextEntryStart);
var junk = new Junk(slice);
if (withSpans) {
junk.addSpan(entryStartPos, nextEntryStart);
}
if (withJunkAnnotations) {
var annot = new Annotation(e.getCode().name(), e.getMessage());
annot.getArguments().addAll(Arrays.asList(e.getArgs()));
annot.addSpan(errorIndex, errorIndex);
junk.addAnnotation(annot);
}
return junk;
}
}
private Entry getEntry(FluentStream ps) {
Optional<Character> currentChar = ps.currentChar();
if (currentChar.filter(v -> v == '#').isPresent()) {
return getComment(ps);
}
if (currentChar.filter(v -> v == '-').isPresent()) {
return getTerm(ps);
}
if (ps.isIdentifierStart()) {
return getMessage(ps);
}
throw new ParseException(E0002);
}
private Message getMessage(FluentStream ps) {
int spanStart = ps.index;
Identifier id = getIdentifier(ps);
ps.skipBlankInline();
ps.expectChar('=');
Pattern value = maybeGetPattern(ps);
Collection<Attribute> attrs = getAttributes(ps);
if (value == null && attrs.isEmpty()) {
throw new ParseException(E0005, id.getName());
}
var msg = new Message(id, value);
msg.getAttributes().addAll(attrs);
if (withSpans) {
msg.addSpan(spanStart, ps.index);
}
return msg;
}
private Term getTerm(FluentStream ps) {
int spanStart = ps.index;
ps.expectChar('-');
Identifier id = getIdentifier(ps);
ps.skipBlankInline();
ps.expectChar('=');
Pattern value = maybeGetPattern(ps);
if (value == null) {
throw new ParseException(E0006, id.getName());
}
Collection<Attribute> attrs = getAttributes(ps);
var term = new Term(id, value);
term.getAttributes().addAll(attrs);
if (withSpans) {
term.addSpan(spanStart, ps.index);
}
return term;
}
private Collection<Attribute> getAttributes(FluentStream ps) {
List<Attribute> attrs = new ArrayList<>();
ps.peekBlank();
while (ps.isAttributeStart()) {
ps.skipToPeek();
Attribute attr = getAttribute(ps);
attrs.add(attr);
ps.peekBlank();
}
return attrs;
}
private Attribute getAttribute(FluentStream ps) {
int spanStart = ps.index;
ps.expectChar('.');
Identifier key = getIdentifier(ps);
ps.skipBlankInline();
ps.expectChar('=');
Pattern value = maybeGetPattern(ps);
if (value == null) {
throw new ParseException(E0012);
}
var attribute = new Attribute(key, value);
if (withSpans) {
attribute.addSpan(spanStart, ps.index);
}
return attribute;
}
// maybeGetPattern distinguishes between patterns which start on the same line
// as the identifier (a.k.a. inline signleline patterns and inline multiline
// patterns) and patterns which start on a new line (a.k.a. block multiline
// patterns). The distinction is important for the dedentation logic: the
// indent of the first line of a block pattern must be taken into account when
// calculating the maximum common indent.
private Pattern maybeGetPattern(FluentStream ps) {
ps.peekBlankInline();
if (ps.isValueStart()) {
ps.skipToPeek();
return getPattern(ps, false);
}
ps.peekBlankBlock();
if (ps.isValueContinuation()) {
ps.skipToPeek();
return getPattern(ps, true);
}
return null;
}
private Pattern getPattern(FluentStream ps, boolean isBlock) {
int spanStart = ps.index;
List<PatternElement> elements = new ArrayList<>();
int commonIndentLength = Integer.MAX_VALUE;
if (isBlock) {
// A block pattern is a pattern which starts on a new line. Store and
// measure the indent of this first line for the dedentation logic.
int blankStart = ps.index;
String firstIndent = ps.skipBlankInline();
elements.add(getIndent(ps, firstIndent, blankStart));
commonIndentLength = firstIndent.length();
}
Optional<Character> opt;
elements:
while ((opt = ps.currentChar()).isPresent()) {
Character ch = opt.get();
switch (ch) {
case EOL -> {
int blankStart = ps.index;
String blankLines = ps.peekBlankBlock();
if (ps.isValueContinuation()) {
ps.skipToPeek();
String indent = ps.skipBlankInline();
commonIndentLength = Math.min(commonIndentLength, indent.length());
elements.add(getIndent(ps, blankLines + indent, blankStart));
continue;
}
// The end condition for getPattern's while loop is a newline
// which is not followed by a valid pattern continuation.
ps.resetPeek();
break elements;
}
case '{' -> elements.add(getPlaceable(ps));
case '}' -> throw new ParseException(E0027);
default -> elements.add(getTextElement(ps));
}
}
List<PatternElement> dedented = dedent(elements, commonIndentLength);
var pattern = new Pattern(dedented);
if (withSpans) {
pattern.addSpan(spanStart, ps.index);
}
return pattern;
}
// Dedent a list of elements by removing the maximum common indent from the
// beginning of text lines. The common indent is calculated in getPattern.
private List<PatternElement> dedent(Collection<PatternElement> elements, int commonIndent) {
ArrayList<PatternElement> trimmed = new ArrayList<>();
for (PatternElement element : elements) {
if (element instanceof Placeable pl) {
trimmed.add(pl);
continue;
}
if (element instanceof Indent ind) {
// Strip common indent.
ind.setValue(ind.getValue().substring(0, (ind.getValue().length() - commonIndent)));
if (ind.getValue().isEmpty()) {
continue;
}
}
if (!trimmed.isEmpty()) {
PatternElement prev = trimmed.getLast();
if (prev instanceof TextElement prevTE) {
// Join adjacent TextElements by replacing them with their sum.
TextElement sum = getTextElement(element, prevTE);
trimmed.set(trimmed.size() - 1, sum);
continue;
}
}
if (element instanceof Indent elmInd) {
// If the indent hasn't been merged into a preceding TextElement,
// convert it into a new TextElement.
var textElement = new TextElement(elmInd.getValue());
if (withSpans && elmInd.getSpan().isPresent()) {
textElement.addSpan(elmInd.getSpan().get().getStart(), elmInd.getSpan().get().getEnd());
}
trimmed.add(textElement);
continue;
}
// The element is a TextElement or a Placeable
trimmed.add(element);
}
// Trim trailing whitespace from the Pattern.
PatternElement lastElement = trimmed.getLast();
if (lastElement instanceof TextElement lastElmTE) {
lastElmTE.setValue(TRAILING_WS_RE.matcher(lastElmTE.getValue()).replaceAll(""));
if (lastElmTE.getValue().isEmpty()) {
trimmed.removeLast();
}
}
return trimmed;
}
private TextElement getTextElement(PatternElement element, TextElement prevTE) {
String newVal;
if (element instanceof TextElement elmTE) {
newVal = elmTE.getValue();
} else if (element instanceof Indent elmInd) {
newVal = elmInd.getValue();
} else {
throw new IllegalStateException("Unexpected PatternElement type");
}
var sum = new TextElement(prevTE.getValue() + newVal);
if (withSpans && prevTE.getSpan().isPresent() && element.getSpan().isPresent()) {
sum.addSpan(prevTE.getSpan().get().getStart(), element.getSpan().get().getEnd());
}
return sum;
}
private TextElement getTextElement(FluentStream ps) {
var buffer = new StringBuilder();
int spanStart = ps.index;
Optional<Character> opt;
while ((opt = ps.currentChar()).isPresent()) {
char ch = opt.get();
if (ch == '{' || ch == '}' || ch == EOL) {
var textElement = new TextElement(buffer.toString());
if (withSpans) {
textElement.addSpan(spanStart, ps.index);
}
return textElement;
}
buffer.append(ch);
ps.next();
}
var textElement = new TextElement(buffer.toString());
if (withSpans) {
textElement.addSpan(spanStart, ps.index);
}
return textElement;
}
private PatternElement getPlaceable(FluentStream ps) {
int spanStart = ps.index;
ps.expectChar('{');
ps.skipBlank();
SyntaxNode expression;
if (ps.currentChar().filter(v -> v == '{').isPresent()) {
PatternElement child = getPlaceable(ps);
ps.skipBlank();
expression = child;
} else {
expression = getExpression(ps);
}
ps.expectChar('}');
var placeable = new Placeable((InsidePlaceable) expression);
if (withSpans) {
placeable.addSpan(spanStart, ps.index);
}
return placeable;
}
private Expression getExpression(FluentStream ps) {
int spanStart = ps.index;
Expression selector = getInlineExpression(ps);
ps.skipBlank();
if (ps.currentChar().filter(v -> v == '-').isPresent()) {
if (ps.peek().filter(v -> v != '>').isPresent()) {
ps.resetPeek();
return selector;
}
// Validate selector expression according to
// abstract.js in the Fluent specification
if (selector instanceof MessageReference mr) {
throw new ParseException(mr.getAttribute().isEmpty() ? E0016 : E0018);
} else if (selector instanceof TermReference tr && tr.getAttribute().isEmpty()) {
throw new ParseException(E0017);
}
ps.next();
ps.next();
ps.skipBlankInline();
ps.expectLineEnd();
List<Variant> variants = getVariants(ps);
var selectExpression = new SelectExpression(selector, variants);
if (withSpans) {
selectExpression.addSpan(spanStart, ps.index);
}
return selectExpression;
}
if (selector instanceof TermReference tr && tr.getAttribute().isPresent()) {
throw new ParseException(E0019);
}
return selector;
}
private List<Variant> getVariants(FluentStream ps) {
List<Variant> variants = new ArrayList<>();
var hasDefault = false;
ps.skipBlank();
while (ps.isVariantStart()) {
Variant variant = getVariant(ps, hasDefault);
if (variant.isDefault()) {
hasDefault = true;
}
variants.add(variant);
ps.expectLineEnd();
ps.skipBlank();
}
if (variants.isEmpty()) {
throw new ParseException(E0011);
}
if (!hasDefault) {
throw new ParseException(E0010);
}
return variants;
}
private Variant getVariant(FluentStream ps, boolean hasDefault) {
int spanStart = ps.index;
var defaultIndex = false;
if (ps.currentChar().filter(v -> v == '*').isPresent()) {
if (hasDefault) {
throw new ParseException(E0015);
}
ps.next();
defaultIndex = true;
}
ps.expectChar('[');
ps.skipBlank();
VariantKey key = getVariantKey(ps);
ps.skipBlank();
ps.expectChar(']');
//val value = this.maybeGetPattern(ps) ?: throw ParseError("E0012")
Pattern value = maybeGetPattern(ps);
if (value == null) {
throw new ParseException(E0012);
}
var variant = new Variant(key, value, defaultIndex);
if (withSpans) {
variant.addSpan(spanStart, ps.index);
}
return variant;
}
private VariantKey getVariantKey(FluentStream ps) {
int cc = ps.currentChar().orElseThrow(() -> new ParseException(E0013));
if (inRange_09(cc) || cc == 45 /*-*/) {
return getNumber(ps);
}
return getIdentifier(ps);
}
private Expression getInlineExpression(FluentStream ps) {
int spanStart = ps.index;
if (ps.isNumberStart()) {
return getNumber(ps);
}
if (ps.currentChar().filter(v -> v == '"').isPresent()) {
return getString(ps);
}
if (ps.currentChar().filter(v -> v == '$').isPresent()) {
ps.next();
Identifier id = getIdentifier(ps);
var variableReference = new VariableReference(id);
if (withSpans) {
variableReference.addSpan(spanStart, ps.index);
}
return variableReference;
}
if (ps.currentChar().filter(v -> v == '-').isPresent()) {
ps.next();
Identifier id = getIdentifier(ps);
Identifier attr = null;
if (ps.currentChar().filter(v -> v == '.').isPresent()) {
ps.next();
attr = getIdentifier(ps);
}
CallArguments args = null;
ps.peekBlank();
if (ps.currentPeek().filter(v -> v == '(').isPresent()) {
ps.skipToPeek();
args = getCallArguments(ps);
}
var termReference = new TermReference(id, attr, args);
if (withSpans) {
termReference.addSpan(spanStart, ps.index);
}
return termReference;
}
if (ps.isIdentifierStart()) {
Identifier id = getIdentifier(ps);
ps.peekBlank();
if (ps.currentPeek().filter(v -> v == '(').isPresent()) {
// It's a Function. Ensure it's all upper-case.
if (!VALID_FUNCTION_NAME.matcher(id.getName()).matches()) {
throw new ParseException(E0008);
}
ps.skipToPeek();
CallArguments args = getCallArguments(ps);
var functionReference = new FunctionReference(id, args);
if (withSpans) {
functionReference.addSpan(spanStart, ps.index);
}
return functionReference;
}
Identifier attr = null;
if (ps.currentChar().filter(v -> v == '.').isPresent()) {
ps.next();
attr = this.getIdentifier(ps);
}
var messageReference = new MessageReference(id, attr);
if (withSpans) {
messageReference.addSpan(spanStart, ps.index);
}
return messageReference;
}
throw new ParseException(E0028);
}
private CallArguments getCallArguments(FluentStream ps) {
int spanStart = ps.index;
List<Expression> positional = new ArrayList<>();
List<NamedArgument> named = new ArrayList<>();
Set<String> argumentNames = new HashSet<>();
ps.expectChar('(');
ps.skipBlank();
while (true) {
if (ps.currentChar().filter(v -> v == ')').isPresent()) {
break;
}
CallArgument arg = getCallArgument(ps);
if (arg instanceof NamedArgument na) {
if (argumentNames.contains(na.getName().getName())) {
throw new ParseException(E0022);
}
named.add(na);
argumentNames.add(na.getName().getName());
} else if (arg instanceof Expression exp) {
if (!argumentNames.isEmpty()) {
throw new ParseException(E0021);
}
positional.add(exp);
}
ps.skipBlank();
if (ps.currentChar().filter(v -> v == ',').isPresent()) {
ps.next();
ps.skipBlank();
continue;
}
break;
}
ps.expectChar(')');
var args = new CallArguments();
args.getPositional().addAll(positional);
args.getNamed().addAll(named);
if (withSpans) {
args.addSpan(spanStart, ps.index);
}
return args;
}
private CallArgument getCallArgument(FluentStream ps) {
int spanStart = ps.index;
Expression exp = getInlineExpression(ps);
ps.skipBlank();
if (ps.currentChar().filter(v -> v != ':').isPresent()) {
return exp;
}
if (exp instanceof MessageReference mr && mr.getAttribute().isEmpty()) {
ps.next();
ps.skipBlank();
Literal value = getLiteral(ps);
var namedArgument = new NamedArgument(mr.getId(), value);
if (withSpans) {
namedArgument.addSpan(spanStart, ps.index);
}
return namedArgument;
}
throw new ParseException(E0009);
}
private Literal getLiteral(FluentStream ps) {
if (ps.isNumberStart()) {
return getNumber(ps);
}
if (ps.currentChar().filter(v -> v == '"').isPresent()) {
return getString(ps);
}
throw new ParseException(E0014);
}
private StringLiteral getString(FluentStream ps) {
int spanStart = ps.index;
ps.expectChar('"');
var value = new StringBuilder();
Predicate<Character> filter = x -> x != '"' && x != EOL;
Optional<Character> opt;
while ((opt = ps.takeChar(filter)).isPresent()) {
Character ch = opt.get();
value.append(ch == '\\' ? getEscapeSequence(ps) : ch);
}
if (ps.currentChar().filter(v -> v == EOL).isPresent()) {
throw new ParseException(E0020);
}
ps.expectChar('"');
var stringLiteral = new StringLiteral(value.toString());
if (withSpans) {
stringLiteral.addSpan(spanStart, ps.index);
}
return stringLiteral;
}
private String getEscapeSequence(FluentStream ps) {
Optional<Character> nextOpt = ps.currentChar();
if (nextOpt.isEmpty()) {
throw new ParseException(E0025, (Character) null);
}
Character next = nextOpt.get();
return switch (next) {
case '\\', '"' -> {
ps.next();
yield "\\" + next;
}
case 'u' -> getUnicodeEscapeSequence(ps, next, 4);
case 'U' -> getUnicodeEscapeSequence(ps, next, 6);
default -> throw new ParseException(E0025, next);
};
}
private String getUnicodeEscapeSequence(FluentStream ps, Character u, int digits) {
ps.expectChar(u);
var sequence = new StringBuilder();
for (int i = 0; i < digits; i++) {
Optional<Character> opt = ps.takeHexDigit();
if (opt.isEmpty()) {
throw new ParseException(E0026, "\\%s%s%s".formatted(u, sequence, ps.currentChar().orElseThrow()));
}
sequence.append(opt.get());
}
return "\\%s%s".formatted(u, sequence);
}
private NumberLiteral getNumber(FluentStream ps) {
int spanStart = ps.index;
var value = new StringBuilder();
if (ps.currentChar().filter(v -> v == '-').isPresent()) {
ps.next();
value.append("-");
}
value.append(getDigits(ps));
if (ps.currentChar().filter(v -> v == '.').isPresent()) {
ps.next();
value.append(".").append(getDigits(ps));
}
var numberLiteral = new NumberLiteral(value.toString());
if (withSpans) {
numberLiteral.addSpan(spanStart, ps.index);
}
return numberLiteral;
}
private String getDigits(FluentStream ps) {
var num = new StringBuilder();
Optional<Character> opt;
while ((opt = ps.takeDigit()).isPresent()) {
num.append(opt.get());
}
if (num.isEmpty()) {
throw new ParseException(E0004, "0-9");
}
return num.toString();
}
// Create a token representing an indent. It's not part of the AST and it will
// be trimmed and merged into adjacent TextElements, or turned into a new
// TextElement, if it's surrounded by two Placeables.
private PatternElement getIndent(FluentStream ps, String value, int start) {
return new Indent(value, start, ps.index);
}
private Identifier getIdentifier(FluentStream ps) {
int spanStart = ps.index;
StringBuilder name = new StringBuilder().append(ps.takeIDStart());
Optional<Character> opt;
while ((opt = ps.takeIDChar()).isPresent()) {
name.append(opt.get());
}
var identifier = new Identifier(name.toString());
if (withSpans) {
identifier.addSpan(spanStart, ps.index);
}
return identifier;
}
private BaseComment getComment(FluentStream ps) {
int spanStart = ps.index;
final int ANY = -1;
final int COMMENT = 0;
final int GROUP_COMMENT = 1;
final int RESOURCE_COMMENT = 2;
int level = ANY;
var content = new StringBuilder();
while (true) {
int i = -1;
int thisLevel;
if (level == ANY) {
thisLevel = RESOURCE_COMMENT;
} else {
thisLevel = level;
}
while (ps.currentChar().filter(v -> v =='#').isPresent() && i < thisLevel) {
ps.next();
i++;
}
if (level == ANY) {
level = i;
}
if (ps.currentChar().filter(v -> v == EOL).isEmpty()) {
ps.expectChar(' ');
Optional<Character> opt;
while ((opt = ps.takeChar(v -> v != EOL)).isPresent()) {
content.append(opt.get());
}
}
if (ps.isNextLineComment(level)) {
ps.currentChar().ifPresent(content::append);
ps.next();
} else {
break;
}
}
BaseComment comment = switch (level) {
case COMMENT -> new Comment(content.toString());
case GROUP_COMMENT -> new GroupComment(content.toString());
default -> new ResourceComment(content.toString());
};
if (withSpans) {
comment.addSpan(spanStart, ps.index);
}
return comment;
}
}

View File

@@ -0,0 +1,259 @@
package ru.di9.fluent.syntax.parser;
import ru.di9.fluent.syntax.utils.MathUtils;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import static ru.di9.fluent.syntax.utils.StringUtils.*;
import static ru.di9.fluent.syntax.parser.ParseException.ErrorCode.E0003;
import static ru.di9.fluent.syntax.parser.ParseException.ErrorCode.E0004;
public class FluentStream extends ParserStream {
public static final char EOL = '\n';
public static final String SPECIAL_LINE_START_CHARS = "}.[*";
public static final Pattern VALID_FUNCTION_NAME = Pattern.compile("^[A-Z][A-Z0-9_-]*$");
public static final Pattern TRAILING_WS_RE = Pattern.compile("[ \t\n\r]+$");
public FluentStream(String string) {
super(string);
}
public String peekBlankInline() {
int start = index + peekOffset;
while (currentPeek().filter(v -> v == ' ').isPresent()) {
peek();
}
return string.substring(start, (index + peekOffset));
}
public String skipBlankInline() {
String blank = peekBlankInline();
skipToPeek();
return blank;
}
public String peekBlankBlock() {
StringBuilder blank = new StringBuilder();
while (true) {
int lineStart = peekOffset;
peekBlankInline();
Optional<Character> currentPeek = currentPeek();
if (currentPeek.filter(v -> v == EOL).isPresent()) {
blank.append(EOL);
peek();
continue;
}
if (currentPeek.isEmpty()/*EOF*/) {
// Treat the blank line at EOF as a blank block.
return blank.toString();
}
// Any other char; reset to column 1 on this line.
resetPeek(lineStart);
return blank.toString();
}
}
public String skipBlankBlock() {
String blank = peekBlankBlock();
skipToPeek();
return blank;
}
public void peekBlank() {
while (currentPeek().filter(v -> v == ' ' || v == EOL).isPresent()) {
peek();
}
}
public void skipBlank() {
peekBlank();
skipToPeek();
}
public void expectChar(char ch) {
currentChar().filter(v -> v == ch)
.orElseThrow(() -> new ParseException(E0003, ch));
next();
}
public void expectLineEnd() {
Optional<Character> opt = currentChar();
if (opt.isEmpty()/*EOF*/) {
// EOF is a valid line end in Fluent.
return;
}
if (opt.get() == EOL) {
next();
return;
}
// Unicode Character 'SYMBOL FOR NEWLINE' (U+2424)
//noinspection UnnecessaryUnicodeEscape
throw new ParseException(E0003, "\u2424");
}
public Optional<Character> takeChar(Predicate<Character> f) {
Optional<Character> ch = currentChar().filter(f);
ch.ifPresent(x -> next());
return ch;
}
public boolean isIdentifierStart() {
return currentPeek().map(this::isCharIdStart).orElse(false);
}
public boolean isNumberStart() {
Optional<Character> opt = currentChar();
if (opt.filter(v -> v == '-').isPresent()) {
opt = peek();
}
boolean result = opt.map(ch -> inRange_09((int) ch)).orElse(false);
resetPeek();
return result;
}
public boolean isValueStart() {
// Inline Patterns may start with any char.
return currentPeek().filter(v -> v != EOL).isPresent();
}
public boolean isValueContinuation() {
int column1 = peekOffset;
peekBlankInline();
if (currentPeek().filter(v -> v == '{').isPresent()) {
resetPeek(column1);
return true;
}
if (peekOffset - column1 == 0) {
return false;
}
if (currentPeek().map(this::isCharPatternContinuation).orElse(false)) {
resetPeek(column1);
return true;
}
return false;
}
/**
* @param level <ul>
* <li>-1 - any
* <li>0 - comment
* <li>1 - group comment
* <li>2 - resource comment
*/
public boolean isNextLineComment(int level) {
if (currentChar().filter(v -> v != EOL).isPresent()) {
return false;
}
int lvl = MathUtils.clamp(level, -1, 2);
int i = 0;
while (i <= lvl || (lvl == -1 && i < 3)) {
if (peek().filter(v -> v != '#').isPresent()) {
if (i <= lvl && lvl != -1) {
resetPeek();
return false;
}
break;
}
i++;
}
// The first char after #, ## or ###.
boolean result = peek().filter(ch -> ch == ' ' || ch == EOL).isPresent();
resetPeek();
return result;
}
public boolean isVariantStart() {
int currentPeekOffset = peekOffset;
if (currentPeek().filter(v -> v == '*').isPresent()) {
peek();
}
boolean result = currentPeek().filter(v -> v == '[').isPresent();
resetPeek(currentPeekOffset);
return result;
}
public boolean isAttributeStart() {
return currentPeek().filter(v -> v == '.').isPresent();
}
public void skipToNextEntryStart(int junkStart) {
int lastNewline = string.lastIndexOf(EOL, index);
if (junkStart < lastNewline) {
// Last seen newline is _after_ the junk start. It's safe to rewind
// without the risk of resuming at the same broken entry.
index = lastNewline;
}
while (currentChar().isPresent()) {
// We're only interested in beginnings of line.
if (currentChar().filter(v -> v != EOL).isPresent()) {
next();
continue;
}
// Break if the first char in this line looks like an entry start.
Optional<Character> firstOpt = next();
if (firstOpt.isEmpty()/*EOF*/) {
continue;
}
char first = firstOpt.get();
if (isCharIdStart(first) || first == '-' || first == '#') {
break;
}
}
}
public char takeIDStart() {
if (currentChar().map(this::isCharIdStart).orElse(false)) {
Optional<Character> ret = currentChar();
if (ret.isPresent()) {
next();
return ret.get();
}
}
throw new ParseException(E0004, "a-zA-Z");
}
public Optional<Character> takeIDChar() {
return takeChar(ch -> {
int cc = ch;
return inRange_az(cc) || inRange_AZ(cc) || inRange_09(cc) || cc == 95 /*_*/ || cc == 45 /*-*/;
});
}
public Optional<Character> takeDigit() {
return takeChar(ch -> inRange_09((int) ch));
}
public Optional<Character> takeHexDigit() {
return takeChar(ch -> {
int cc = ch;
return inRange_09(cc) || inRange_AF(cc) || inRange_af(cc);
});
}
/// PRIVATE ////////////////////////////////////////////////////////////////////////////////////////////////////////
private boolean isCharIdStart(char ch) {
return inRange_az(ch) || inRange_AZ(ch);
}
private boolean isCharPatternContinuation(char ch) {
return SPECIAL_LINE_START_CHARS.indexOf(ch) < 0;
}
}

View File

@@ -0,0 +1,18 @@
package ru.di9.fluent.syntax.parser;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import ru.di9.fluent.syntax.ast.PatternElement;
@AllArgsConstructor
@Getter
@Setter
public class Indent extends PatternElement {
private String value;
public Indent(String value, int start, int end) {
this(value);
this.addSpan(start, end);
}
}

View File

@@ -0,0 +1,50 @@
package ru.di9.fluent.syntax.parser;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
public class ParseException extends RuntimeException {
private final ErrorCode code;
private final Object[] args;
public ParseException(ErrorCode code, Object... args) {
super(code.getMessage().formatted(args));
this.code = code;
this.args = args;
}
@RequiredArgsConstructor
@Getter
public enum ErrorCode {
E0001("Generic Error"),
E0002("Expected an entry start"),
E0003("Expected token: \"%s\""),
E0004("Expected a character from range: \"%s\""),
E0005("Expected message \"%s\" to have a value or attributes"),
E0006("Expected term \"-%s\" to have a value"),
E0007("Keyword cannot end with a whitespace"),
E0008("The callee has to be an upper-case identifier or a term"),
E0009("The argument name has to be a simple identifier"),
E0010("Expected one of the variants to be marked as default (*)"),
E0011("Expected at least one variant after \"->\""),
E0012("Expected value"),
E0013("Expected variant key"),
E0014("Expected literal"),
E0015("Only one variant can be marked as default (*)"),
E0016("Message references cannot be used as selectors"),
E0017("Terms cannot be used as selectors"),
E0018("Attributes of messages cannot be used as selectors"),
E0019("Attributes of terms cannot be used as placeables"),
E0020("Unterminated string expression"),
E0021("Positional arguments must not follow named arguments"),
E0022("Named arguments must be unique"),
E0024("Cannot access variants of a message."),
E0025("Unknown escape sequence: \\%s."),
E0026("Invalid Unicode escape sequence: %s."),
E0027("Unbalanced closing brace in TextElement."),
E0028("Expected an inline expression");
private final String message;
}
}

View File

@@ -0,0 +1,81 @@
package ru.di9.fluent.syntax.parser;
import java.util.Optional;
import static ru.di9.fluent.syntax.utils.StringUtils.getCharAt;
public class ParserStream {
protected int index = 0;
protected int peekOffset = 0;
protected String string;
public ParserStream(String string) {
this.string = string;
}
public Optional<Character> currentChar() {
return charAt(index);
}
public Optional<Character> currentPeek() {
return charAt(index + peekOffset);
}
public Optional<Character> next() {
peekOffset = 0;
if (index >= string.length()) {
return Optional.empty();
}
// Skip over the CRLF as if it was a single character.
getCharAt(string, index)
.filter(v -> v == '\r')
.flatMap(x -> getCharAt(string, index + 1).filter(v -> v == '\n'))
.ifPresent(x -> index++);
index++;
return getCharAt(string, index);
}
public Optional<Character> peek() {
// Skip over the CRLF as if it was a single character.
getCharAt(string, index + peekOffset)
.filter(v -> v == '\r')
.flatMap(x -> getCharAt(string, index + peekOffset + 1).filter(v -> v == '\n'))
.ifPresent(x -> peekOffset++);
peekOffset++;
return getCharAt(string, index + peekOffset);
}
public void resetPeek() {
resetPeek(0);
}
public void resetPeek(int offset) {
this.peekOffset = offset;
}
public void skipToPeek() {
this.index += this.peekOffset;
this.peekOffset = 0;
}
/// PRIVATE ////////////////////////////////////////////////////////////////////////////////////////////////////////
private Optional<Character> charAt(int offset) {
// When the cursor is at CRLF, return LF but don't move the cursor.
// The cursor still points to the EOL position, which in this case is the
// beginning of the compound CRLF sequence. This ensures slices of
// [inclusive, exclusive) continue to work properly.
Optional<Character> ch = getCharAt(string, offset);
Optional<Character> opt = ch.filter(v -> v == '\r')
.flatMap(x -> getCharAt(string, offset + 1)
.filter(v -> v == '\n'));
return opt.isPresent() ? opt : ch;
}
}

View File

@@ -0,0 +1,165 @@
package ru.di9.fluent.syntax.processor;
import ru.di9.fluent.syntax.ast.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
public class Processor {
private static final java.util.regex.Pattern SPECIAL
= java.util.regex.Pattern.compile("\\\\(([\\\\\"])|(u[0-9a-fA-F]{4})|(U[0-90a-fA-F]{6}))");
public Pattern unescapeLiteralsToText(Pattern pattern) {
var result = new Pattern();
textFromLiterals(pattern, result.getElements());
return result;
}
public Pattern escapeTextToLiterals(Pattern pattern) {
var result = new Pattern();
literalsFromText(pattern, result.getElements());
return result;
}
private void textFromLiterals(Pattern pattern, Collection<PatternElement> collection) {
TextElement lastText = null;
for (PatternElement element : pattern.getElements()) {
if (element instanceof TextElement txtElm) {
if (lastText == null) {
lastText = new TextElement(txtElm.getValue());
} else {
lastText.setValue(lastText.getValue() + txtElm.getValue());
}
} else if (element instanceof Placeable plcElm) {
InsidePlaceable expression = plcElm.getExpression();
if (expression instanceof StringLiteral strExp) {
String content = strExp.getValue();
{
Matcher matcher = SPECIAL.matcher(content);
var sb = new StringBuilder();
while (matcher.find()) {
String replacement = unescape(matcher.toMatchResult()).replace("\\", "\\\\");
matcher.appendReplacement(sb, replacement);
}
matcher.appendTail(sb);
content = sb.toString();
}
if (lastText == null) {
lastText = new TextElement("");
}
lastText.setValue(lastText.getValue() + content);
} else if (expression instanceof SelectExpression selExp) {
List<Variant> processedVariants = new ArrayList<>();
for (Variant variant : selExp.getVariants()) {
processedVariants.add(new Variant(variant.getKey(), unescapeLiteralsToText(variant.getValue()), variant.isDefault()));
}
var processedSelect = new SelectExpression(selExp.getSelector(), processedVariants);
var placeable = new Placeable(processedSelect);
if (lastText != null) {
collection.add(lastText);
lastText = null;
}
collection.add(placeable);
} else {
if (lastText != null) {
collection.add(lastText);
lastText = null;
}
collection.add(plcElm);
}
}
}
if (lastText != null) {
collection.add(lastText);
}
}
private void literalsFromText(Pattern pattern, Collection<PatternElement> collection) {
for (PatternElement element : pattern.getElements()) {
if (element instanceof TextElement txtElm) {
if (txtElm.getValue().startsWith(" ") || txtElm.getValue().startsWith("\n")) {
collection.add(new Placeable(new StringLiteral("")));
}
int startIndex = 0;
for (int i = 0; i < txtElm.getValue().length(); i++) {
char ch = txtElm.getValue().charAt(i);
if (ch == '{' || ch == '}') {
String before = txtElm.getValue().substring(startIndex, i);
if (!before.isEmpty()) {
collection.add(new TextElement(before));
}
collection.add(new Placeable(new StringLiteral(String.valueOf(ch))));
startIndex = i + 1;
} else if (ch == '[' || ch == '*' || ch == '.') {
if (i > 0 && txtElm.getValue().charAt(i - 1) == '\n') {
String before = txtElm.getValue().substring(startIndex, i);
collection.add(new TextElement(before));
collection.add(new Placeable(new StringLiteral(String.valueOf(ch))));
startIndex = i + 1;
}
}
}
if ((txtElm.getValue().length() - 1) > startIndex) {
collection.add(new TextElement(txtElm.getValue().substring(startIndex)));
}
if (txtElm.getValue().endsWith(" ") || txtElm.getValue().endsWith("\n")) {
collection.add(new Placeable(new StringLiteral("")));
}
} else if (element instanceof Placeable plcElm) {
if (plcElm.getExpression() instanceof SelectExpression selExp) {
List<Variant> rawVariants = new ArrayList<>();
for (Variant variant : selExp.getVariants()) {
rawVariants.add(new Variant(variant.getKey(), escapeTextToLiterals(variant.getValue()), variant.isDefault()));
}
collection.add(new Placeable(new SelectExpression(selExp.getSelector(), rawVariants)));
} else {
collection.add(element);
}
}
}
}
private static String unescape(MatchResult matchResult) {
List<String> groupValues = new ArrayList<>();
for (int i = 2; i < matchResult.groupCount() + 1; i++) {
groupValues.add(matchResult.group(i) != null ? matchResult.group(i) : "");
}
Iterator<String> matches = groupValues.iterator();
String simple = matches.next();
if (!simple.isEmpty()) {
return simple;
}
String uni4 = matches.next();
if (!uni4.isEmpty()) {
int codepoint = Integer.valueOf(uni4.substring(1), 16);
if (Character.isBmpCodePoint(codepoint)) {
char chr = (char) codepoint;
if (!Character.isSurrogate(chr)) {
return Character.toString(chr);
}
}
}
String uni6 = matches.next();
if (!uni6.isEmpty()) {
int codepoint = Integer.valueOf(uni6.substring(1), 16);
if (Character.isValidCodePoint(codepoint)) {
char chr = (char) codepoint;
if (!Character.isSurrogate(chr)) {
return String.valueOf(Character.highSurrogate(codepoint)) + Character.lowSurrogate(codepoint);
}
}
}
return "\uFFFD"; // <20>
}
}

View File

@@ -0,0 +1,257 @@
package ru.di9.fluent.syntax.serializer;
import ru.di9.fluent.syntax.ast.*;
import java.util.Arrays;
import java.util.Objects;
import java.util.stream.Collectors;
public class FluentSerializer {
public boolean withJunk = false;
public String serialize(Resource resource) {
return resource.getBody()
.stream()
.map(this::serialize)
.collect(Collectors.joining());
}
public String serialize(TopLevel topLevel) {
return switch (topLevel) {
case Entry entry -> serializeEntry(entry);
case Whitespace whitespace -> whitespace.getContent();
case Junk junk -> withJunk ? junk.getContent() : "";
default -> throw new SerializeException("Unknown top-level entry type '%s'".formatted(topLevel.getClass()));
};
}
/// PRIVATE ////////////////////////////////////////////////////////////////////////////////////////////////////////
private String serializeEntry(Entry entry) {
return switch (entry) {
case Message message -> serializeMessage(message);
case Term term -> serializeTerm(term);
case Comment comment -> serializeComment(comment, "#");
case GroupComment comment -> serializeComment(comment, "##");
case ResourceComment comment -> serializeComment(comment, "###");
default -> throw new SerializeException("Unknown entry type '%s'".formatted(entry.getClass()));
};
}
private String serializeMessage(Message message) {
var builder = new StringBuilder();
message.getComment().ifPresent(comment ->
builder.append(serializeComment(comment, "#")));
builder.append(message.getId().getName()).append(" =");
message.getValue().ifPresent(value ->
builder.append(serializePattern(value)));
for (Attribute attribute : message.getAttributes()) {
builder.append(serializeAttribute(attribute));
}
return builder.append('\n').toString();
}
private String serializeTerm(Term term) {
var builder = new StringBuilder();
term.getComment().ifPresent(comment ->
builder.append(serializeComment(comment, "#")));
builder
.append('-').append(term.getId().getName()).append(" =")
.append(serializePattern(term.getValue()));
for (Attribute attribute : term.getAttributes()) {
builder.append(serializeAttribute(attribute));
}
return builder.append('\n').toString();
}
private String serializeComment(BaseComment comment, String prefix) {
return Arrays.stream(comment.getContent().split("\n"))
.map(line -> {
if (line.isEmpty()) {
return prefix + "\n";
} else {
return prefix + " " + line + "\n";
}
})
.collect(Collectors.joining());
}
private String serializePattern(Pattern pattern) {
var builder = new StringBuilder();
String content = pattern.getElements()
.stream()
.map(this::serializeElement)
.filter(Objects::nonNull)
.map(string -> string.replaceAll("\n", "\n "))
.collect(Collectors.joining());
if (shouldStartOnNewLine(pattern)) {
builder.append("\n ");
} else {
builder.append(' ');
}
return builder.append(content).toString();
}
private String serializeAttribute(Attribute attribute) {
String value = serializePattern(attribute.getValue()).replaceAll("\n", "\n ");
return "\n ." + attribute.getId().getName() + " =" + value;
}
private String serializeElement(PatternElement patternElement) {
if (patternElement instanceof TextElement textElement) {
return textElement.getValue();
} else if (patternElement instanceof Placeable placeable) {
return serializePlaceable(placeable);
} else {
throw new SerializeException("Unknown element type: '%s".formatted(patternElement.getClass()));
}
}
private String serializePlaceable(Placeable placeable) {
InsidePlaceable expression = placeable.getExpression();
return switch (expression) {
case Placeable placeable1 -> "{" + serializePlaceable(placeable1) + "}";
case SelectExpression selectExpression -> "{ " + serializeExpression(selectExpression) + "}";
case Expression expression1 -> "{ " + serializeExpression(expression1) + " }";
default -> throw new SerializeException("Unknown placeable type '%s'".formatted(expression.getClass()));
};
}
private String serializeExpression(Expression expression) {
var builder = new StringBuilder();
switch (expression) {
case StringLiteral stringLiteral -> builder.append('"').append(stringLiteral.getValue()).append('"');
case NumberLiteral numberLiteral -> builder.append(numberLiteral.getValue());
case VariableReference variableReference -> builder.append('$').append(variableReference.getId().getName());
case TermReference termReference -> {
builder.append('-').append(termReference.getId().getName());
termReference.getAttribute().ifPresent(attribute ->
builder.append('.').append(attribute.getName()));
termReference.getArguments().ifPresent(arguments ->
builder.append(serializeCallArguments(arguments)));
}
case MessageReference messageReference -> {
builder.append(messageReference.getId().getName());
messageReference.getAttribute().ifPresent(attribute ->
builder.append('.').append(attribute.getName()));
}
case FunctionReference functionReference -> builder
.append(functionReference.getId().getName())
.append(serializeCallArguments(functionReference.getArguments()));
case SelectExpression selectExpression -> {
builder.append(serializeExpression(selectExpression.getSelector())).append(" ->");
for (Variant variant : selectExpression.getVariants()) {
builder.append(serializeVariant(variant));
}
builder.append('\n');
}
default -> throw new SerializeException("Unknown expression type '%s".formatted(expression.getClass()));
}
return builder.toString();
}
private String serializeCallArguments(CallArguments callArguments) {
boolean hasPositional = !callArguments.getPositional().isEmpty();
boolean hasNamed = !callArguments.getNamed().isEmpty();
var builder = new StringBuilder("(");
if (hasPositional) {
String positional = callArguments.getPositional()
.stream()
.map(this::serializeExpression)
.collect(Collectors.joining(", "));
builder.append(positional);
}
if (hasNamed) {
String named = callArguments.getNamed()
.stream()
.map(this::serializeNamedArgument)
.collect(Collectors.joining(", "));
if (hasPositional) {
builder.append(", ");
}
builder.append(named);
}
return builder.append(')').toString();
}
private String serializeVariant(Variant variant) {
String key = serializeVariantKey(variant.getKey());
String value = serializePattern(variant.getValue()).replaceAll("\n", "\n ");
var builder = new StringBuilder("\n ");
if (variant.isDefault()) {
builder.append('*');
} else {
builder.append(' ');
}
return builder.append('[').append(key).append(']').append(value).toString();
}
private String serializeNamedArgument(NamedArgument namedArgument) {
return namedArgument.getName().getName() + ": " + serializeExpression(namedArgument.getValue());
}
private String serializeVariantKey(VariantKey variantKey) {
if (variantKey instanceof Identifier identifier) {
return identifier.getName();
} else if (variantKey instanceof NumberLiteral numberLiteral) {
return numberLiteral.getValue();
} else {
throw new SerializeException("Unknown variant key type '%s'".formatted(variantKey.getClass()));
}
}
private boolean shouldStartOnNewLine(Pattern pattern) {
boolean isMultiline = pattern.getElements().stream().anyMatch(it -> isSelectExpr(it) || includesLine(it));
if (isMultiline) {
if (!pattern.getElements().isEmpty()) {
PatternElement firstElement = pattern.getElements().getFirst();
if (firstElement instanceof TextElement te) {
if (!te.getValue().isEmpty()) {
char firstChar = te.getValue().charAt(0);
// Due to the indentation requirement the following characters may not appear
// as the first character on a new line.
return firstChar != '[' && firstChar != '.' && firstChar != '*';
}
}
}
return true;
}
return false;
}
private boolean isSelectExpr(PatternElement patternElement) {
return patternElement instanceof Placeable pl
&& pl.getExpression() instanceof SelectExpression;
}
private boolean includesLine(PatternElement patternElement) {
return patternElement instanceof TextElement te
&& te.getValue().contains("\n");
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.serializer;
public class SerializeException extends RuntimeException {
public SerializeException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package ru.di9.fluent.syntax.utils;
public interface MathUtils {
static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(value, max));
}
}

View File

@@ -0,0 +1,38 @@
package ru.di9.fluent.syntax.utils;
import java.util.Optional;
public interface StringUtils {
static Optional<Character> getCharAt(String string, int index) {
if (string == null || string.isEmpty()) {
return Optional.empty();
}
if (index >= 0 && index <= string.length() - 1) {
return Optional.of(string.charAt(index));
} else {
return Optional.empty();
}
}
static boolean inRange_az(int cc) {
return (97 <= cc && cc <= 122); // a-z
}
static boolean inRange_AZ(int cc) {
return (65 <= cc && cc <= 90); // A-Z
}
static boolean inRange_af(int cc) {
return (97 <= cc && cc <= 102); // a-f
}
static boolean inRange_AF(int cc) {
return (65 <= cc && cc <= 70); // A-F
}
static boolean inRange_09(int cc) {
return (48 <= cc && cc <= 57); // 0-9
}
}

View File

@@ -0,0 +1,113 @@
package ru.di9.fluent.syntax.visitor;
import ru.di9.fluent.syntax.ast.BaseNode;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
public class Visitor {
private final Map<String, Method> handlers = new HashMap<>();
public Visitor() {
Method[] methods = this.getClass().getDeclaredMethods();
for (Method method : methods) {
if (method.getName().startsWith("visit")) {
handlers.put(method.getName().substring("visit".length()), method);
}
}
}
public void visit(BaseNode node) {
String cName = node.getClass().getSimpleName();
Method handler = handlers.get(cName);
if (handler != null) {
try {
handler.invoke(this, node);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
} else {
genericVisit(node);
}
}
public void genericVisit(BaseNode node) {
Iterator<Pair> itr = childrenOf(node);
while (itr.hasNext()) {
Pair pair = itr.next();
Object value = pair.invokeResult();
if (value instanceof Optional<?> opt) {
value = opt.orElse(null);
}
if (value instanceof BaseNode baseNode) {
this.visit(baseNode);
} else if (value instanceof Collection<?> collection) {
for (Object it : collection) {
if (it instanceof BaseNode baseNode) {
this.visit(baseNode);
}
}
}
}
}
record Pair(String name, Object invokeResult){}
static Iterator<Pair> childrenOf(BaseNode node) {
final Method[] methods = node.getClass().getMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
return new Iterator<>() {
private int idx = 0;
private Method nextMethod;
@Override
public boolean hasNext() {
if (nextMethod != null) return true;
else if (idx >= methods.length) return false;
return (nextMethod = loadNext()) != null;
}
@Override
public Pair next() {
if (idx >= methods.length) {
throw new NoSuchElementException();
} else if (nextMethod == null) {
if ((nextMethod = loadNext()) == null) {
throw new NoSuchElementException();
}
}
Object invokeResult;
try {
invokeResult = nextMethod.invoke(node);
} catch (InvocationTargetException | IllegalAccessException e) {
throw new RuntimeException(e);
}
var pair = new Pair(nextMethod.getName(), invokeResult);
nextMethod = null;
return pair;
}
private Method loadNext() {
do {
Method method = methods[idx++];
if (Modifier.isPublic(method.getModifiers())
&& method.getName().startsWith("get")
&& method.getParameterCount() == 0
&& !method.getName().equals("getClass")) {
return method;
}
} while (idx < methods.length);
return null;
}
};
}
}

View File

@@ -0,0 +1,22 @@
package ru.di9.fluent.syntax.ast;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BaseNodeTest {
@Test
void testEquals() {
var m1 = new Message(new Identifier("test-id"), new Pattern(new TextElement("localized")));
var m11 = new Message(new Identifier("test-id"), new Pattern(new TextElement("localized")));
var m2 = new Message(new Identifier("test-id"), new Pattern(new TextElement("different")));
assertThat(m1)
.isEqualTo(m11)
.isNotEqualTo(m2);
assertThat(m1.getId()).isEqualTo(m2.getId());
assertThat(m1.getValue()).isNotEqualTo(m2.getValue());
}
}

View File

@@ -0,0 +1,67 @@
package ru.di9.fluent.syntax.parser;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import ru.di9.fluent.syntax.ast.Resource;
import ru.di9.fluent.syntax.test.utils.AstAssert;
import ru.di9.fluent.test.utils.Tuple3;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static ru.di9.fluent.test.utils.FileUtils.getExt;
import static ru.di9.fluent.test.utils.FileUtils.getName;
public abstract class AbstractFixturesTest {
@TestFactory
public Iterable<DynamicTest> fixturesTest() throws IOException {
List<DynamicTest> tests = new ArrayList<>();
try (Stream<Path> walker = Files.walk(getFixturesPath())) {
walker
.filter(path -> path.toFile().isFile())
.filter(path -> getExt(path.toFile().getName()).equals("ftl"))
.map(path -> {
Path parent = path.getParent();
String name = getName(path.getFileName().toString());
try {
return new Tuple3(
name,
Files.readString(parent.resolve(name + ".ftl")),
Files.readString(parent.resolve(name + ".json"))
);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.map(tuple -> DynamicTest.dynamicTest(tuple.value1(), () -> {
var parser = new FluentParser();
parser.withSpans = isWithSpans(tuple.value1());
parser.withJunkAnnotations = isWithJunkAnnotations(tuple.value1());
Resource resource = parser.parse(tuple.value2());
AstAssert.assertThat(resource)
.isEqualAstJson(tuple.value3());
}))
.forEachOrdered(tests::add);
}
return tests;
}
protected abstract Path getFixturesPath();
protected boolean isWithSpans(String testName) {
return true;
}
protected boolean isWithJunkAnnotations(String testName) {
return true;
}
}

View File

@@ -0,0 +1,158 @@
package ru.di9.fluent.syntax.parser;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static ru.di9.fluent.syntax.parser.ParseException.ErrorCode.E0003;
class FluentStreamTest {
@Test
void testPeekBlankInline() {
var ps = new FluentStream(" ");
assertThat(ps.peekBlankInline()).isEqualTo(" ");
ps = new FluentStream(" | ");
assertThat(ps.peekBlankInline()).isEqualTo(" ");
}
@Test
void testPeekBlankBlock() {
var ps = new FluentStream(" \n \n ");
assertThat(ps.peekBlankBlock()).isEqualTo("\n\n");
ps = new FluentStream(" \n . ");
assertThat(ps.peekBlankBlock()).isEqualTo("\n");
}
@Test
void testSkipBlankBlock() {
var ps = new FluentStream(" \n \n ");
assertThat(ps.skipBlankBlock()).isEqualTo("\n\n");
assertThat(ps.index).isEqualTo(12);
assertThat(ps.peekOffset).isEqualTo(0);
}
@Test
void testExpectLineEnd() {
var ps1 = new FluentStream("");
assertThatNoException().isThrownBy(ps1::expectLineEnd);
var ps2 = new FluentStream("\n");
assertThatNoException().isThrownBy(ps2::expectLineEnd);
var ps3 = new FluentStream(" ");
ParseException catchException = catchThrowableOfType(ParseException.class, ps3::expectLineEnd);
assertThat(catchException.getCode()).isEqualTo(E0003);
}
@Test
void testExpectChar() {
var ps = new FluentStream("z");
assertThatNoException().isThrownBy(() -> ps.expectChar('z'));
ParseException catchException = catchThrowableOfType(ParseException.class, () -> ps.expectChar('a'));
assertThat(catchException.getCode()).isEqualTo(E0003);
}
@Test
void testTakeChar() {
var ps = new FluentStream("abc");
assertThat(ps.currentChar()).hasValue('a');
assertThat(ps.takeChar(c -> c == 'a')).hasValue('a');
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.takeChar(c -> c == 'c')).isEmpty();
assertThat(ps.currentChar()).hasValue('b');
}
@Test
void testIsNextLineComment() {
var ps = new FluentStream("# 123");
for (int i = 0; i < 5; i++) {
ps.next();
}
assertThat(ps.isNextLineComment(0)).isFalse();
var ps1 = new FluentStream("# 123\n# 456");
for (int i = 0; i < 5; i++) {
ps1.next();
}
assertThat(ps1.isNextLineComment(0)).isTrue();
var ps2 = new FluentStream("# 123\nkey = value");
for (int i = 0; i < 5; i++) {
ps2.next();
}
assertThat(ps2.isNextLineComment(0)).isFalse();
var ps3 = new FluentStream("# 123");
assertThat(ps3.isNextLineComment(0)).isFalse();
}
@Test
void testSkipBlankInline() {
var ps = new FluentStream(" \n123");
assertThat(ps.skipBlankInline()).isEqualTo(" ");
assertThat(ps.index).isEqualTo(4);
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.currentChar()).hasValue('\n');
}
@Test
void testPeekBlank() {
var ps = new FluentStream(" 123");
assertThat(ps.currentChar()).hasValue(' ');
assertThat(ps.currentPeek()).hasValue(' ');
ps.peekBlank();
assertThat(ps.currentChar()).hasValue(' ');
assertThat(ps.currentPeek()).hasValue('1');
}
@Test
void testSkipBlank() {
var ps = new FluentStream(" 123");
assertThat(ps.currentChar()).hasValue(' ');
assertThat(ps.currentPeek()).hasValue(' ');
ps.skipBlank();
assertThat(ps.currentChar()).hasValue('1');
assertThat(ps.currentPeek()).hasValue('1');
}
@Test
void testIsNumberStart() {
var ps = new FluentStream("123");
assertThat(ps.isNumberStart()).isTrue();
var ps1 = new FluentStream("-123");
assertThat(ps1.isNumberStart()).isTrue();
var ps2 = new FluentStream("a123");
assertThat(ps2.isNumberStart()).isFalse();
}
@Test
void testIsIdentifierStart() {
var ps = new FluentStream("foo = Bar");
assertThat(ps.isIdentifierStart()).isTrue();
var ps1 = new FluentStream("# foo = Bar");
assertThat(ps1.isIdentifierStart()).isFalse();
}
@Test
void testIsValueStart() {
var ps = new FluentStream("foo = Bar\n");
assertThat(ps.isValueStart()).isTrue();
for (int i = 0; i < 9; i++) {
ps.next();
}
assertThat(ps.isValueStart()).isFalse();
}
}

View File

@@ -0,0 +1,181 @@
package ru.di9.fluent.syntax.parser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ParserStreamTest {
ParserStream ps;
@BeforeEach
void setUp() {
ps = new ParserStream("abcd");
}
@Test
void next() {
assertThat(ps.currentChar()).hasValue('a');
assertThat(ps.index).isEqualTo(0);
assertThat(ps.next()).hasValue('b');
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.index).isEqualTo(1);
assertThat(ps.next()).hasValue('c');
assertThat(ps.currentChar()).hasValue('c');
assertThat(ps.index).isEqualTo(2);
assertThat(ps.next()).hasValue('d');
assertThat(ps.currentChar()).hasValue('d');
assertThat(ps.index).isEqualTo(3);
assertThat(ps.next()).isEmpty();
assertThat(ps.currentChar()).isEmpty();
assertThat(ps.index).isEqualTo(4);
}
@Test
void peek() {
assertThat(ps.currentPeek()).hasValue('a');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.peek()).hasValue('b');
assertThat(ps.currentPeek()).hasValue('b');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.peek()).hasValue('c');
assertThat(ps.currentPeek()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(2);
assertThat(ps.peek()).hasValue('d');
assertThat(ps.currentPeek()).hasValue('d');
assertThat(ps.peekOffset).isEqualTo(3);
assertThat(ps.peek()).isEmpty();
assertThat(ps.currentPeek()).isEmpty();
assertThat(ps.peekOffset).isEqualTo(4);
}
@Test
void peekAndNext() {
assertThat(ps.peek()).hasValue('b');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(0);
assertThat(ps.next()).hasValue('b');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(1);
assertThat(ps.peek()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(1);
assertThat(ps.next()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(2);
assertThat(ps.currentChar()).hasValue('c');
assertThat(ps.currentPeek()).hasValue('c');
assertThat(ps.peek()).hasValue('d');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(2);
assertThat(ps.next()).hasValue('d');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(3);
assertThat(ps.currentChar()).hasValue('d');
assertThat(ps.currentPeek()).hasValue('d');
assertThat(ps.peek()).isEmpty();
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(3);
assertThat(ps.currentChar()).hasValue('d');
assertThat(ps.currentPeek()).isEmpty();
assertThat(ps.peek()).isEmpty();
assertThat(ps.peekOffset).isEqualTo(2);
assertThat(ps.index).isEqualTo(3);
assertThat(ps.next()).isEmpty();
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(4);
}
@Test
void skipAndPeek() {
ps.peek();
ps.peek();
ps.skipToPeek();
assertThat(ps.currentChar()).hasValue('c');
assertThat(ps.currentPeek()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(2);
ps.peek();
assertThat(ps.currentChar()).hasValue('c');
assertThat(ps.currentPeek()).hasValue('d');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(2);
ps.next();
assertThat(ps.currentChar()).hasValue('d');
assertThat(ps.currentPeek()).hasValue('d');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(3);
}
@Test
void resetPeek() {
ps.next();
ps.peek();
ps.peek();
ps.resetPeek();
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.currentPeek()).hasValue('b');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(1);
ps.peek();
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.currentPeek()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(1);
ps.peek();
ps.peek();
ps.peek();
ps.resetPeek();
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.currentPeek()).hasValue('b');
assertThat(ps.peekOffset).isEqualTo(0);
assertThat(ps.index).isEqualTo(1);
assertThat(ps.peek()).hasValue('c');
assertThat(ps.currentChar()).hasValue('b');
assertThat(ps.currentPeek()).hasValue('c');
assertThat(ps.peekOffset).isEqualTo(1);
assertThat(ps.index).isEqualTo(1);
assertThat(ps.peek()).hasValue('d');
assertThat(ps.peek()).isEmpty();
}
@Test
void indexOutOfRange() {
ps.next();
ps.next();
ps.next();
ps.next();
assertThat(ps.index).isGreaterThanOrEqualTo(ps.string.length());
assertThat(ps.next()).isEmpty();
}
}

View File

@@ -0,0 +1,21 @@
package ru.di9.fluent.syntax.parser;
import java.nio.file.Path;
public class ReferenceTest extends AbstractFixturesTest {
@Override
protected Path getFixturesPath() {
return Path.of("src", "test", "resources", "reference_fixtures");
}
@Override
protected boolean isWithSpans(String testName) {
return !testName.equals("crlf") && !testName.equals("select_expressions");
}
@Override
protected boolean isWithJunkAnnotations(String testName) {
return !testName.equals("crlf") && !testName.equals("select_expressions");
}
}

View File

@@ -0,0 +1,11 @@
package ru.di9.fluent.syntax.parser;
import java.nio.file.Path;
public class StructureTest extends AbstractFixturesTest {
@Override
protected Path getFixturesPath() {
return Path.of("src", "test", "resources", "structure_fixtures");
}
}

View File

@@ -0,0 +1,210 @@
package ru.di9.fluent.syntax.processor;
import org.junit.jupiter.api.Test;
import ru.di9.fluent.syntax.ast.*;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProcessorTest {
@Test
void toProcessedPattern() {
var processor = new Processor();
var pattern = new Pattern();
pattern.getElements().clear();
pattern.getElements().add(new TextElement("Hi"));
assertEquals(pattern, processor.unescapeLiteralsToText(pattern));
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new Placeable(new StringLiteral("\\\\")),
new TextElement(" "),
new Placeable(new StringLiteral("\\\""))
));
assertEquals(
new Pattern(new TextElement("\\ \"")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Foo "),
new Placeable(new StringLiteral("Bar"))
));
assertEquals(
new Pattern(new TextElement("Foo Bar")),
processor.unescapeLiteralsToText(pattern)
);
assertEquals(
new Pattern(
new TextElement("Foo "),
new Placeable(new StringLiteral("Bar"))
),
pattern
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Hi,"),
new Placeable(new StringLiteral("\\u0020")),
new TextElement("there")
));
assertEquals(
new Pattern(new TextElement("Hi, there")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Emoji: "),
new Placeable(new StringLiteral("\\U01f602"))
));
assertEquals(
new Pattern(new TextElement("Emoji: \uD83D\uDE02")),
processor.unescapeLiteralsToText(pattern)
);
assertEquals(
new Pattern(new TextElement("Emoji: 😂")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Illegal escape sequence: "),
new Placeable(new StringLiteral("\\ud800"))
));
assertEquals(
new Pattern(new TextElement("Illegal escape sequence: <20>")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Illegal escape sequence: "),
new Placeable(new StringLiteral("\\U00d800"))
));
assertEquals(
new Pattern(new TextElement("Illegal escape sequence: <20>")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Hi, "),
new Placeable(new StringLiteral("{")),
new TextElement(" there")
));
assertEquals(
new Pattern(new TextElement("Hi, { there")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Foo\n"),
new Placeable(new StringLiteral(".")),
new TextElement("bar")
));
assertEquals(
new Pattern(new TextElement("Foo\n.bar")),
processor.unescapeLiteralsToText(pattern)
);
pattern.getElements().clear();
pattern.getElements().addAll(List.of(
new TextElement("Foo "),
new Placeable(
new SelectExpression(
new NumberLiteral("1"),
List.of(
new Variant(
new Identifier("other"),
new Pattern(
new TextElement("bar "),
new Placeable(
new StringLiteral("{-_-}")
)
),
true
)
)
)
),
new TextElement(" baz")
));
assertEquals(
new Pattern(
new TextElement("Foo "),
new Placeable(
new SelectExpression(
new NumberLiteral("1"),
List.of(
new Variant(
new Identifier("other"),
new Pattern(new TextElement("bar {-_-}")),
true)
)
)
),
new TextElement(" baz")
),
processor.unescapeLiteralsToText(pattern)
);
}
@Test
void toRawPattern() {
var processor = new Processor();
var pattern = new Pattern();
pattern.getElements().clear();
pattern.getElements().add(new TextElement("Hi"));
assertEquals(pattern, processor.escapeTextToLiterals(pattern));
pattern.getElements().clear();
pattern.getElements().add(new TextElement("\\ \""));
assertEquals(pattern, processor.escapeTextToLiterals(pattern));
pattern.getElements().clear();
pattern.getElements().add(new TextElement("Hi, {-_-}"));
assertEquals(
new Pattern(
new TextElement("Hi, "),
new Placeable(new StringLiteral("{")),
new TextElement("-_-"),
new Placeable(new StringLiteral("}"))
),
processor.escapeTextToLiterals(pattern)
);
pattern.getElements().clear();
pattern.getElements().add(new TextElement("Foo\nbar"));
assertEquals(pattern, processor.escapeTextToLiterals(pattern));
pattern.getElements().clear();
pattern.getElements().add(new TextElement("Foo\n*bar"));
assertEquals(
new Pattern(
new TextElement("Foo\n"),
new Placeable(new StringLiteral("*")),
new TextElement("bar")
),
processor.escapeTextToLiterals(pattern)
);
pattern.getElements().clear();
pattern.getElements().add(new TextElement("\nFoo\nbar "));
assertEquals(
new Pattern(
new Placeable(new StringLiteral("")),
new TextElement("\nFoo\nbar "),
new Placeable(new StringLiteral(""))
),
processor.escapeTextToLiterals(pattern)
);
}
}

View File

@@ -0,0 +1,25 @@
package ru.di9.fluent.syntax.serializer;
import org.junit.jupiter.api.Test;
import ru.di9.fluent.syntax.ast.TopLevel;
import ru.di9.fluent.syntax.parser.FluentParser;
import static org.assertj.core.api.Assertions.assertThat;
class SerializeEntryTest {
@Test
void test() {
String input = """
# Attached comment
key = Value""";
var parser = new FluentParser();
TopLevel topLevel = parser.parse(input).getBody().getFirst();
var serializer = new FluentSerializer();
String serialized = serializer.serialize(topLevel).trim();
assertThat(serialized).isEqualTo(input);
}
}

View File

@@ -0,0 +1,68 @@
package ru.di9.fluent.syntax.serializer;
import org.junit.jupiter.api.DynamicNode;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import ru.di9.fluent.syntax.ast.Resource;
import ru.di9.fluent.syntax.parser.FluentParser;
import ru.di9.fluent.test.utils.Tuple3;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static ru.di9.fluent.test.utils.FileUtils.getExt;
import static ru.di9.fluent.test.utils.FileUtils.getName;
class SerializeResourceTest {
@TestFactory
Iterable<DynamicTest> test() throws IOException {
SortedSet<DynamicTest> tests = new TreeSet<>(Comparator.comparing(DynamicNode::getDisplayName));
try (Stream<Path> walker = Files.walk(Path.of("src", "test", "resources", "serialized"))) {
walker
.filter(path -> path.toFile().isFile())
.filter(path -> getExt(path).equals("ftl"))
.map(path -> {
Path parent = path.getParent();
String name = getName(path);
try {
Path ftlSourcePath = parent.resolve(name + ".ftl");
Path expSourcePath = parent.resolve(name + ".exp.txt");
String ftlSource = Files.readString(ftlSourcePath);
String expSource;
if (Files.exists(expSourcePath)) {
expSource = Files.readString(expSourcePath);
} else {
expSource = ftlSource;
}
return new Tuple3(name, ftlSource, expSource);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.map(tuple -> DynamicTest.dynamicTest(tuple.value1(), () -> {
FluentParser parser = new FluentParser();
Resource resource = parser.parse(tuple.value2());
FluentSerializer serializer = new FluentSerializer();
String serialized = serializer.serialize(resource);
assertThat(serialized).isEqualTo(tuple.value3());
}))
.forEach(tests::add);
}
return tests;
}
}

View File

@@ -0,0 +1,51 @@
package ru.di9.fluent.syntax.test.utils;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.assertj.core.api.AbstractAssert;
import org.json.JSONException;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import ru.di9.fluent.syntax.ast.BaseNode;
import ru.di9.fluent.test.utils.BaseNodeJsonSerializer;
import ru.di9.fluent.test.utils.NullIgnoreComparator;
import java.io.PrintStream;
@SuppressWarnings("UnusedReturnValue")
public class AstAssert extends AbstractAssert<AstAssert, BaseNode> {
private final Gson gson;
private final NullIgnoreComparator nullIgnoreComparator;
protected AstAssert(BaseNode actualNode) {
super(actualNode, AstAssert.class);
gson = new GsonBuilder()
.setPrettyPrinting()
.registerTypeHierarchyAdapter(BaseNode.class, new BaseNodeJsonSerializer())
.create();
nullIgnoreComparator = new NullIgnoreComparator(JSONCompareMode.STRICT);
}
@SuppressWarnings("unused")
public AstAssert printAstJson(PrintStream printStream) {
String json = gson.toJson(actual);
printStream.println(json);
return this;
}
public AstAssert isEqualAstJson(String astJson) {
String actualJson = gson.toJson(actual);
try {
JSONAssert.assertEquals(astJson, actualJson, nullIgnoreComparator);
} catch (JSONException e) {
failWithMessage(e.getMessage());
}
return this;
}
public static AstAssert assertThat(BaseNode baseNode) {
return new AstAssert(baseNode);
}
}

View File

@@ -0,0 +1,15 @@
package ru.di9.fluent.syntax.utils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MathUtilsTest {
@Test
void clamp() {
assertThat(MathUtils.clamp(7, 1, 10)).isEqualTo(7);
assertThat(MathUtils.clamp(0, 1, 10)).isEqualTo(1);
assertThat(MathUtils.clamp(11, 1, 10)).isEqualTo(10);
}
}

View File

@@ -0,0 +1,33 @@
package ru.di9.fluent.syntax.utils;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Optional;
class StringUtilsTest {
@Test
void testGetChar() {
String string = "abcd";
Optional<Character> chr = StringUtils.getCharAt(string, 0);
Assertions.assertThat(chr)
.hasValue('a');
chr = StringUtils.getCharAt(string, 4);
Assertions.assertThat(chr)
.isEmpty();
chr = StringUtils.getCharAt(null, 0);
Assertions.assertThat(chr)
.isEmpty();
chr = StringUtils.getCharAt("", 0);
Assertions.assertThat(chr)
.isEmpty();
}
}

View File

@@ -0,0 +1,77 @@
package ru.di9.fluent.syntax.visitor;
import lombok.Getter;
import lombok.Setter;
import org.junit.jupiter.api.Test;
import ru.di9.fluent.syntax.ast.*;
import ru.di9.fluent.syntax.parser.FluentParser;
import java.util.Iterator;
import java.util.List;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.regex.Matcher;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static org.junit.jupiter.api.Assertions.*;
class VisitorTest {
FluentParser parser = new FluentParser();
@Test
void test_basics() {
var visitor = new TestableVisitor();
var source = """
msg = foo {$var ->
*[other] bar
} baz""";
Resource res = parser.parse(source);
visitor.visit(res);
assertEquals(3, visitor.wordCount);
assertEquals(2, visitor.patternCount);
assertEquals(1, visitor.variantCount);
}
@Test
void test_childrenOf() {
var variant = new Variant(new Identifier("other"), new Pattern(), true);
Iterator<Visitor.Pair> itr = Visitor.childrenOf(variant);
Spliterator<Visitor.Pair> spliterator = Spliterators.spliteratorUnknownSize(itr, Spliterator.ORDERED);
Stream<Visitor.Pair> stream = StreamSupport.stream(spliterator, false);
assertEquals(
List.of("getKey", "getSpan", "getValue"),
stream.map(Visitor.Pair::name).sorted().toList()
);
}
@Getter
@Setter
@SuppressWarnings("unused")
static class TestableVisitor extends Visitor {
private static final java.util.regex.Pattern WORDS = java.util.regex.Pattern.compile("\\w+");
private int patternCount = 0;
private int variantCount = 0;
private int wordCount = 0;
public void visitPattern(Pattern node) {
super.genericVisit(node);
patternCount++;
}
public void visitVariant(Variant node) {
super.genericVisit(node);
variantCount++;
}
public void visitTextElement(TextElement node) {
Matcher matcher = WORDS.matcher(node.getValue());
while (matcher.find()) {
wordCount++;
}
}
}
}

View File

@@ -0,0 +1,43 @@
package ru.di9.fluent.test.utils;
import com.google.gson.*;
import org.joor.Reflect;
import ru.di9.fluent.syntax.ast.BaseNode;
import ru.di9.fluent.syntax.ast.Whitespace;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.Map;
public class BaseNodeJsonSerializer implements JsonSerializer<BaseNode> {
@Override
public JsonElement serialize(BaseNode baseNode, Type type, JsonSerializationContext ctx) {
var root = new JsonObject();
root.add("type", new JsonPrimitive(baseNode.getClass().getSimpleName()));
Map<String, Reflect> fields = Reflect.on(baseNode).fields();
for (Map.Entry<String, Reflect> field : fields.entrySet()) {
String name = field.getKey().equalsIgnoreCase("isDefault") ? "default" : field.getKey();
Object value = field.getValue().get();
if (value instanceof Collection<?> collection) {
var jsonArray = new JsonArray();
for (Object item : collection) {
if (item instanceof Whitespace) {
continue;
}
jsonArray.add(ctx.serialize(item));
}
root.add(name, jsonArray);
} else {
root.add(name, ctx.serialize(value));
}
}
return root;
}
}

View File

@@ -0,0 +1,26 @@
package ru.di9.fluent.test.utils;
import java.nio.file.Path;
public interface FileUtils {
static String getExt(String fileName) {
int idx = fileName.lastIndexOf('.');
if (idx < 0) return "";
return fileName.substring(idx + 1);
}
static String getName(String fileName) {
int idx = fileName.lastIndexOf('.');
if (idx < 0) return fileName;
return fileName.substring(0, idx);
}
static String getName(Path pathToFile) {
return getName(pathToFile.getFileName().toString());
}
static String getExt(Path pathToFile) {
return getExt(pathToFile.getFileName().toString());
}
}

View File

@@ -0,0 +1,32 @@
package ru.di9.fluent.test.utils;
import org.json.JSONException;
import org.json.JSONObject;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.skyscreamer.jsonassert.JSONCompareResult;
import org.skyscreamer.jsonassert.comparator.DefaultComparator;
import java.util.Set;
import static org.skyscreamer.jsonassert.comparator.JSONCompareUtil.getKeys;
import static org.skyscreamer.jsonassert.comparator.JSONCompareUtil.qualify;
public class NullIgnoreComparator extends DefaultComparator {
public NullIgnoreComparator(JSONCompareMode mode) {
super(mode);
}
@Override
protected void checkJsonObjectKeysExpectedInActual(String prefix, JSONObject expected, JSONObject actual, JSONCompareResult result) throws JSONException {
Set<String> expectedKeys = getKeys(expected);
for (String key : expectedKeys) {
Object expectedValue = expected.get(key);
if (actual.has(key)) {
Object actualValue = actual.get(key);
compareValues(qualify(prefix, key), expectedValue, actualValue, result);
} else if (!expectedValue.equals(JSONObject.NULL)) {
result.missing(prefix, key);
}
}
}
}

View File

@@ -0,0 +1,4 @@
package ru.di9.fluent.test.utils;
public record Tuple3(String value1, String value2, String value3) {
}

View File

@@ -0,0 +1,2 @@
crlf.ftl eol=crlf
cr.ftl eol=cr

View File

@@ -0,0 +1,8 @@
# ↓ BEL, U+0007
control0 = abcdef
# ↓ DEL, U+007F
delete = abcdef
# ↓ BPM, U+0082
control1 = abc‚def

View File

@@ -0,0 +1,148 @@
{
"type": "Resource",
"body": [
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "control0",
"span": {
"type": "Span",
"start": 28,
"end": 36
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "abc\u0007def",
"span": {
"type": "Span",
"start": 39,
"end": 46
}
}
],
"span": {
"type": "Span",
"start": 39,
"end": 46
}
},
"attributes": [],
"comment": {
"content": " ↓ BEL, U+0007",
"type": "Comment",
"span": {
"type": "Span",
"start": 0,
"end": 27
}
},
"span": {
"type": "Span",
"start": 0,
"end": 46
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "delete",
"span": {
"type": "Span",
"start": 74,
"end": 80
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "abcdef",
"span": {
"type": "Span",
"start": 83,
"end": 90
}
}
],
"span": {
"type": "Span",
"start": 83,
"end": 90
}
},
"attributes": [],
"comment": {
"content": " ↓ DEL, U+007F",
"type": "Comment",
"span": {
"type": "Span",
"start": 48,
"end": 73
}
},
"span": {
"type": "Span",
"start": 48,
"end": 90
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "control1",
"span": {
"type": "Span",
"start": 120,
"end": 128
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "abc‚def",
"span": {
"type": "Span",
"start": 131,
"end": 138
}
}
],
"span": {
"type": "Span",
"start": 131,
"end": 138
}
},
"attributes": [],
"comment": {
"content": " ↓ BPM, U+0082",
"type": "Comment",
"span": {
"type": "Span",
"start": 92,
"end": 119
}
},
"span": {
"type": "Span",
"start": 92,
"end": 138
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 139
}
}

View File

@@ -0,0 +1,20 @@
face-with-tears-of-joy = 😂
tetragram-for-centre = 𝌆
surrogates-in-text = \uD83D\uDE02
surrogates-in-string = {"\uD83D\uDE02"}
surrogates-in-adjacent-strings = {"\uD83D"}{"\uDE02"}
emoji-in-text = A face 😂 with tears of joy.
emoji-in-string = {"A face 😂 with tears of joy."}
# ERROR Invalid identifier
err-😂 = Value
# ERROR Invalid expression
err-invalid-expression = { 😂 }
# ERROR Invalid variant key
err-invalid-variant-key = { $sel ->
*[😂] Value
}

View File

@@ -0,0 +1,414 @@
{
"type": "Resource",
"body": [
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "face-with-tears-of-joy",
"span": {
"type": "Span",
"start": 0,
"end": 22
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "😂",
"span": {
"type": "Span",
"start": 25,
"end": 27
}
}
],
"span": {
"type": "Span",
"start": 25,
"end": 27
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 0,
"end": 27
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "tetragram-for-centre",
"span": {
"type": "Span",
"start": 28,
"end": 48
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "𝌆",
"span": {
"type": "Span",
"start": 51,
"end": 53
}
}
],
"span": {
"type": "Span",
"start": 51,
"end": 53
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 28,
"end": 53
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "surrogates-in-text",
"span": {
"type": "Span",
"start": 55,
"end": 73
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "\\uD83D\\uDE02",
"span": {
"type": "Span",
"start": 76,
"end": 88
}
}
],
"span": {
"type": "Span",
"start": 76,
"end": 88
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 55,
"end": 88
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "surrogates-in-string",
"span": {
"type": "Span",
"start": 89,
"end": 109
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\uD83D\\uDE02",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 113,
"end": 127
}
},
"span": {
"type": "Span",
"start": 112,
"end": 128
}
}
],
"span": {
"type": "Span",
"start": 112,
"end": 128
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 89,
"end": 128
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "surrogates-in-adjacent-strings",
"span": {
"type": "Span",
"start": 129,
"end": 159
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\uD83D",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 163,
"end": 171
}
},
"span": {
"type": "Span",
"start": 162,
"end": 172
}
},
{
"type": "Placeable",
"expression": {
"value": "\\uDE02",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 173,
"end": 181
}
},
"span": {
"type": "Span",
"start": 172,
"end": 182
}
}
],
"span": {
"type": "Span",
"start": 162,
"end": 182
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 129,
"end": 182
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "emoji-in-text",
"span": {
"type": "Span",
"start": 184,
"end": 197
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "A face 😂 with tears of joy.",
"span": {
"type": "Span",
"start": 200,
"end": 228
}
}
],
"span": {
"type": "Span",
"start": 200,
"end": 228
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 184,
"end": 228
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "emoji-in-string",
"span": {
"type": "Span",
"start": 229,
"end": 244
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "A face 😂 with tears of joy.",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 248,
"end": 278
}
},
"span": {
"type": "Span",
"start": 247,
"end": 279
}
}
],
"span": {
"type": "Span",
"start": 247,
"end": 279
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 229,
"end": 279
}
},
{
"content": "ERROR Invalid identifier",
"type": "Comment",
"span": {
"type": "Span",
"start": 281,
"end": 307
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"="
],
"message": "Expected token: \"=\"",
"span": {
"type": "Span",
"start": 312,
"end": 312
}
}
],
"content": "err-😂 = Value\n\n",
"span": {
"type": "Span",
"start": 308,
"end": 324
}
},
{
"content": "ERROR Invalid expression",
"type": "Comment",
"span": {
"type": "Span",
"start": 324,
"end": 350
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0028",
"arguments": [],
"message": "Expected an inline expression",
"span": {
"type": "Span",
"start": 378,
"end": 378
}
}
],
"content": "err-invalid-expression = { 😂 }\n\n",
"span": {
"type": "Span",
"start": 351,
"end": 384
}
},
{
"content": "ERROR Invalid variant key",
"type": "Comment",
"span": {
"type": "Span",
"start": 384,
"end": 411
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0004",
"arguments": [
"a-zA-Z"
],
"message": "Expected a character from range: \"a-zA-Z\"",
"span": {
"type": "Span",
"start": 454,
"end": 454
}
}
],
"content": "err-invalid-variant-key = { $sel ->\n *[😂] Value\n}\n",
"span": {
"type": "Span",
"start": 412,
"end": 466
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 466
}
}

View File

@@ -0,0 +1,120 @@
## Function names
valid-func-name-01 = {FUN1()}
valid-func-name-02 = {FUN_FUN()}
valid-func-name-03 = {FUN-FUN()}
# JUNK 0 is not a valid Identifier start
invalid-func-name-01 = {0FUN()}
# JUNK Function names may not be lowercase
invalid-func-name-02 = {fun()}
# JUNK Function names may not contain lowercase character
invalid-func-name-03 = {Fun()}
# JUNK ? is not a valid Identifier character
invalid-func-name-04 = {FUN?()}
## Arguments
positional-args = {FUN(1, "a", msg)}
named-args = {FUN(x: 1, y: "Y")}
dense-named-args = {FUN(x:1, y:"Y")}
mixed-args = {FUN(1, "a", msg, x: 1, y: "Y")}
# ERROR Positional arg must not follow keyword args
shuffled-args = {FUN(1, x: 1, "a", y: "Y", msg)}
# ERROR Named arguments must be unique
duplicate-named-args = {FUN(x: 1, x: "X")}
## Whitespace around arguments
sparse-inline-call = {FUN ( "a" , msg, x: 1 )}
empty-inline-call = {FUN( )}
multiline-call = {FUN(
"a",
msg,
x: 1
)}
sparse-multiline-call = {FUN
(
"a" ,
msg
, x: 1
)}
empty-multiline-call = {FUN(
)}
unindented-arg-number = {FUN(
1)}
unindented-arg-string = {FUN(
"a")}
unindented-arg-msg-ref = {FUN(
msg)}
unindented-arg-term-ref = {FUN(
-msg)}
unindented-arg-var-ref = {FUN(
$var)}
unindented-arg-call = {FUN(
OTHER())}
unindented-named-arg = {FUN(
x:1)}
unindented-closing-paren = {FUN(
x
)}
## Optional trailing comma
one-argument = {FUN(1,)}
many-arguments = {FUN(1, 2, 3,)}
inline-sparse-args = {FUN( 1, 2, 3, )}
mulitline-args = {FUN(
1,
2,
)}
mulitline-sparse-args = {FUN(
1
,
2
,
)}
## Syntax errors for trailing comma
one-argument = {FUN(1,,)}
missing-arg = {FUN(,)}
missing-sparse-arg = {FUN( , )}
## Whitespace in named arguments
sparse-named-arg = {FUN(
x : 1,
y : 2,
z
:
3
)}
unindented-colon = {FUN(
x
:1)}
unindented-value = {FUN(
x:
1)}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
## Callees in placeables.
function-callee-placeable = {FUNCTION()}
term-callee-placeable = {-term()}
# ERROR Messages cannot be parameterized.
message-callee-placeable = {message()}
# ERROR Equivalent to a MessageReference callee.
mixed-case-callee-placeable = {Function()}
# ERROR Message attributes cannot be parameterized.
message-attr-callee-placeable = {message.attr()}
# ERROR Term attributes may not be used in Placeables.
term-attr-callee-placeable = {-term.attr()}
# ERROR Variables cannot be parameterized.
variable-callee-placeable = {$variable()}
## Callees in selectors.
function-callee-selector = {FUNCTION() ->
*[key] Value
}
term-attr-callee-selector = {-term.attr() ->
*[key] Value
}
# ERROR Messages cannot be parameterized.
message-callee-selector = {message() ->
*[key] Value
}
# ERROR Equivalent to a MessageReference callee.
mixed-case-callee-selector = {Function() ->
*[key] Value
}
# ERROR Message attributes cannot be parameterized.
message-attr-callee-selector = {message.attr() ->
*[key] Value
}
# ERROR Term values may not be used as selectors.
term-callee-selector = {-term() ->
*[key] Value
}
# ERROR Variables cannot be parameterized.
variable-callee-selector = {$variable() ->
*[key] Value
}

View File

@@ -0,0 +1,706 @@
{
"type": "Resource",
"body": [
{
"content": "Callees in placeables.",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 0,
"end": 25
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "function-callee-placeable",
"span": {
"type": "Span",
"start": 27,
"end": 52
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"type": "FunctionReference",
"id": {
"type": "Identifier",
"name": "FUNCTION",
"span": {
"type": "Span",
"start": 56,
"end": 64
}
},
"arguments": {
"type": "CallArguments",
"positional": [],
"named": [],
"span": {
"type": "Span",
"start": 64,
"end": 66
}
},
"span": {
"type": "Span",
"start": 56,
"end": 66
}
},
"span": {
"type": "Span",
"start": 55,
"end": 67
}
}
],
"span": {
"type": "Span",
"start": 55,
"end": 67
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 27,
"end": 67
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "term-callee-placeable",
"span": {
"type": "Span",
"start": 68,
"end": 89
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"type": "TermReference",
"id": {
"type": "Identifier",
"name": "term",
"span": {
"type": "Span",
"start": 94,
"end": 98
}
},
"attribute": null,
"arguments": {
"type": "CallArguments",
"positional": [],
"named": [],
"span": {
"type": "Span",
"start": 98,
"end": 100
}
},
"span": {
"type": "Span",
"start": 93,
"end": 100
}
},
"span": {
"type": "Span",
"start": 92,
"end": 101
}
}
],
"span": {
"type": "Span",
"start": 92,
"end": 101
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 68,
"end": 101
}
},
{
"content": "ERROR Messages cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 103,
"end": 144
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0008",
"arguments": [],
"message": "The callee has to be an upper-case identifier or a term",
"span": {
"type": "Span",
"start": 180,
"end": 180
}
}
],
"content": "message-callee-placeable = {message()}\n",
"span": {
"type": "Span",
"start": 145,
"end": 184
}
},
{
"content": "ERROR Equivalent to a MessageReference callee.",
"type": "Comment",
"span": {
"type": "Span",
"start": 184,
"end": 232
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0008",
"arguments": [],
"message": "The callee has to be an upper-case identifier or a term",
"span": {
"type": "Span",
"start": 272,
"end": 272
}
}
],
"content": "mixed-case-callee-placeable = {Function()}\n",
"span": {
"type": "Span",
"start": 233,
"end": 276
}
},
{
"content": "ERROR Message attributes cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 276,
"end": 327
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"}"
],
"message": "Expected token: \"}\"",
"span": {
"type": "Span",
"start": 373,
"end": 373
}
}
],
"content": "message-attr-callee-placeable = {message.attr()}\n",
"span": {
"type": "Span",
"start": 328,
"end": 377
}
},
{
"content": "ERROR Term attributes may not be used in Placeables.",
"type": "Comment",
"span": {
"type": "Span",
"start": 377,
"end": 431
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0019",
"arguments": [],
"message": "Attributes of terms cannot be used as placeables",
"span": {
"type": "Span",
"start": 474,
"end": 474
}
}
],
"content": "term-attr-callee-placeable = {-term.attr()}\n",
"span": {
"type": "Span",
"start": 432,
"end": 476
}
},
{
"content": "ERROR Variables cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 476,
"end": 518
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"}"
],
"message": "Expected token: \"}\"",
"span": {
"type": "Span",
"start": 557,
"end": 557
}
}
],
"content": "variable-callee-placeable = {$variable()}\n\n\n",
"span": {
"type": "Span",
"start": 519,
"end": 563
}
},
{
"content": "Callees in selectors.",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 563,
"end": 587
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "function-callee-selector",
"span": {
"type": "Span",
"start": 589,
"end": 613
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"type": "SelectExpression",
"selector": {
"type": "FunctionReference",
"id": {
"type": "Identifier",
"name": "FUNCTION",
"span": {
"type": "Span",
"start": 617,
"end": 625
}
},
"arguments": {
"type": "CallArguments",
"positional": [],
"named": [],
"span": {
"type": "Span",
"start": 625,
"end": 627
}
},
"span": {
"type": "Span",
"start": 617,
"end": 627
}
},
"variants": [
{
"type": "Variant",
"key": {
"type": "Identifier",
"name": "key",
"span": {
"type": "Span",
"start": 636,
"end": 639
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value",
"span": {
"type": "Span",
"start": 641,
"end": 646
}
}
],
"span": {
"type": "Span",
"start": 641,
"end": 646
}
},
"default": true,
"span": {
"type": "Span",
"start": 634,
"end": 646
}
}
],
"span": {
"type": "Span",
"start": 617,
"end": 647
}
},
"span": {
"type": "Span",
"start": 616,
"end": 648
}
}
],
"span": {
"type": "Span",
"start": 616,
"end": 648
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 589,
"end": 648
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "term-attr-callee-selector",
"span": {
"type": "Span",
"start": 649,
"end": 674
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"type": "SelectExpression",
"selector": {
"type": "TermReference",
"id": {
"type": "Identifier",
"name": "term",
"span": {
"type": "Span",
"start": 679,
"end": 683
}
},
"attribute": {
"type": "Identifier",
"name": "attr",
"span": {
"type": "Span",
"start": 684,
"end": 688
}
},
"arguments": {
"type": "CallArguments",
"positional": [],
"named": [],
"span": {
"type": "Span",
"start": 688,
"end": 690
}
},
"span": {
"type": "Span",
"start": 678,
"end": 690
}
},
"variants": [
{
"type": "Variant",
"key": {
"type": "Identifier",
"name": "key",
"span": {
"type": "Span",
"start": 699,
"end": 702
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value",
"span": {
"type": "Span",
"start": 704,
"end": 709
}
}
],
"span": {
"type": "Span",
"start": 704,
"end": 709
}
},
"default": true,
"span": {
"type": "Span",
"start": 697,
"end": 709
}
}
],
"span": {
"type": "Span",
"start": 678,
"end": 710
}
},
"span": {
"type": "Span",
"start": 677,
"end": 711
}
}
],
"span": {
"type": "Span",
"start": 677,
"end": 711
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 649,
"end": 711
}
},
{
"content": "ERROR Messages cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 713,
"end": 754
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0008",
"arguments": [],
"message": "The callee has to be an upper-case identifier or a term",
"span": {
"type": "Span",
"start": 789,
"end": 789
}
}
],
"content": "message-callee-selector = {message() ->\n *[key] Value\n}\n",
"span": {
"type": "Span",
"start": 755,
"end": 813
}
},
{
"content": "ERROR Equivalent to a MessageReference callee.",
"type": "Comment",
"span": {
"type": "Span",
"start": 813,
"end": 861
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0008",
"arguments": [],
"message": "The callee has to be an upper-case identifier or a term",
"span": {
"type": "Span",
"start": 900,
"end": 900
}
}
],
"content": "mixed-case-callee-selector = {Function() ->\n *[key] Value\n}\n",
"span": {
"type": "Span",
"start": 862,
"end": 924
}
},
{
"content": "ERROR Message attributes cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 924,
"end": 975
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"}"
],
"message": "Expected token: \"}\"",
"span": {
"type": "Span",
"start": 1020,
"end": 1020
}
}
],
"content": "message-attr-callee-selector = {message.attr() ->\n *[key] Value\n}\n",
"span": {
"type": "Span",
"start": 976,
"end": 1044
}
},
{
"content": "ERROR Term values may not be used as selectors.",
"type": "Comment",
"span": {
"type": "Span",
"start": 1044,
"end": 1093
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0017",
"arguments": [],
"message": "Terms cannot be used as selectors",
"span": {
"type": "Span",
"start": 1126,
"end": 1126
}
}
],
"content": "term-callee-selector = {-term() ->\n *[key] Value\n}\n",
"span": {
"type": "Span",
"start": 1094,
"end": 1147
}
},
{
"content": "ERROR Variables cannot be parameterized.",
"type": "Comment",
"span": {
"type": "Span",
"start": 1147,
"end": 1189
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"}"
],
"message": "Expected token: \"}\"",
"span": {
"type": "Span",
"start": 1227,
"end": 1227
}
}
],
"content": "variable-callee-selector = {$variable() ->\n *[key] Value\n}\n",
"span": {
"type": "Span",
"start": 1190,
"end": 1251
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 1251
}
}

View File

@@ -0,0 +1,20 @@
# Standalone Comment
# Message Comment
foo = Foo
# Term Comment
# with a blank last line.
#
-term = Term
# Another standalone
#
# with indent
## Group Comment
### Resource Comment
# Errors
#error
##error
###error

View File

@@ -0,0 +1,219 @@
{
"type": "Resource",
"body": [
{
"content": "Standalone Comment",
"type": "Comment",
"span": {
"type": "Span",
"start": 0,
"end": 20
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "foo",
"span": {
"type": "Span",
"start": 40,
"end": 43
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Foo",
"span": {
"type": "Span",
"start": 46,
"end": 49
}
}
],
"span": {
"type": "Span",
"start": 46,
"end": 49
}
},
"attributes": [],
"comment": {
"content": "Message Comment",
"type": "Comment",
"span": {
"type": "Span",
"start": 22,
"end": 39
}
},
"span": {
"type": "Span",
"start": 22,
"end": 49
}
},
{
"type": "Term",
"id": {
"type": "Identifier",
"name": "term",
"span": {
"type": "Span",
"start": 95,
"end": 99
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Term",
"span": {
"type": "Span",
"start": 102,
"end": 106
}
}
],
"span": {
"type": "Span",
"start": 102,
"end": 106
}
},
"attributes": [],
"comment": {
"content": "Term Comment\nwith a blank last line.\n",
"type": "Comment",
"span": {
"type": "Span",
"start": 51,
"end": 93
}
},
"span": {
"type": "Span",
"start": 51,
"end": 106
}
},
{
"content": "Another standalone\n\n with indent",
"type": "Comment",
"span": {
"type": "Span",
"start": 108,
"end": 150
}
},
{
"content": "Group Comment",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 151,
"end": 167
}
},
{
"content": "Resource Comment",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 168,
"end": 188
}
},
{
"content": "Errors",
"type": "Comment",
"span": {
"type": "Span",
"start": 190,
"end": 198
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
" "
],
"message": "Expected token: \" \"",
"span": {
"type": "Span",
"start": 200,
"end": 200
}
}
],
"content": "#error\n",
"span": {
"type": "Span",
"start": 199,
"end": 206
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
" "
],
"message": "Expected token: \" \"",
"span": {
"type": "Span",
"start": 208,
"end": 208
}
}
],
"content": "##error\n",
"span": {
"type": "Span",
"start": 206,
"end": 214
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
" "
],
"message": "Expected token: \" \"",
"span": {
"type": "Span",
"start": 217,
"end": 217
}
}
],
"content": "###error\n",
"span": {
"type": "Span",
"start": 214,
"end": 223
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 223
}
}

View File

@@ -0,0 +1 @@
### This entire file uses CR as EOL.

View File

@@ -0,0 +1,19 @@
{
"type": "Resource",
"body": [
{
"type": "ResourceComment",
"content": "This entire file uses CR as EOL.\r\rerr01 = Value 01\rerr02 = Value 02\r\rerr03 =\r\r Value 03\r Continued\r\r .title = Title\r\rerr04 = { \"str\r\rerr05 = { $sel -> }\r",
"span": {
"type": "Span",
"start": 0,
"end": 166
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 166
}
}

View File

@@ -0,0 +1,14 @@
key01 = Value 01
key02 =
Value 02
Continued
.title = Title
# ERROR Unclosed StringLiteral
err03 = { "str
# ERROR Missing newline after ->.
err04 = { $sel -> }

View File

@@ -0,0 +1,76 @@
{
"type": "Resource",
"body": [
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "key01"
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value 01"
}
]
},
"attributes": [],
"comment": null
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "key02"
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value 02\nContinued"
}
]
},
"attributes": [
{
"type": "Attribute",
"id": {
"type": "Identifier",
"name": "title"
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Title"
}
]
}
}
],
"comment": null
},
{
"type": "Comment",
"content": "ERROR Unclosed StringLiteral"
},
{
"type": "Junk",
"annotations": [],
"content": "err03 = { \"str\r\n\r\n"
},
{
"type": "Comment",
"content": "ERROR Missing newline after ->."
},
{
"type": "Junk",
"annotations": [],
"content": "err04 = { $sel -> }\r\n"
}
]
}

View File

@@ -0,0 +1,3 @@
### NOTE: Disable final newline insertion when editing this file.
# No EOL

View File

@@ -0,0 +1,28 @@
{
"type": "Resource",
"body": [
{
"content": "NOTE: Disable final newline insertion when editing this file.",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 0,
"end": 65
}
},
{
"content": "No EOL",
"type": "Comment",
"span": {
"type": "Span",
"start": 67,
"end": 75
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 75
}
}

View File

@@ -0,0 +1,9 @@
{
"type": "Resource",
"body": [],
"span": {
"type": "Span",
"start": 0,
"end": 0
}
}

View File

@@ -0,0 +1,3 @@
### NOTE: Disable final newline insertion when editing this file.
message-id

View File

@@ -0,0 +1,43 @@
{
"type": "Resource",
"body": [
{
"content": "NOTE: Disable final newline insertion when editing this file.",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 0,
"end": 65
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"="
],
"message": "Expected token: \"=\"",
"span": {
"type": "Span",
"start": 77,
"end": 77
}
}
],
"content": "message-id",
"span": {
"type": "Span",
"start": 67,
"end": 77
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 77
}
}

View File

@@ -0,0 +1,3 @@
### NOTE: Disable final newline insertion when editing this file.
message-id =

View File

@@ -0,0 +1,43 @@
{
"type": "Resource",
"body": [
{
"content": "NOTE: Disable final newline insertion when editing this file.",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 0,
"end": 65
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0005",
"arguments": [
"message-id"
],
"message": "Expected message \"message-id\" to have a value or attributes",
"span": {
"type": "Span",
"start": 79,
"end": 79
}
}
],
"content": "message-id =",
"span": {
"type": "Span",
"start": 67,
"end": 79
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 79
}
}

View File

@@ -0,0 +1,3 @@
### NOTE: Disable final newline insertion when editing this file.
000

View File

@@ -0,0 +1,41 @@
{
"type": "Resource",
"body": [
{
"content": "NOTE: Disable final newline insertion when editing this file.",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 0,
"end": 65
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0002",
"arguments": [],
"message": "Expected an entry start",
"span": {
"type": "Span",
"start": 67,
"end": 67
}
}
],
"content": "000",
"span": {
"type": "Span",
"start": 67,
"end": 70
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 70
}
}

View File

@@ -0,0 +1,3 @@
### NOTE: Disable final newline insertion when editing this file.
no-eol = No EOL

View File

@@ -0,0 +1,57 @@
{
"type": "Resource",
"body": [
{
"content": "NOTE: Disable final newline insertion when editing this file.",
"type": "ResourceComment",
"span": {
"type": "Span",
"start": 0,
"end": 65
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "no-eol",
"span": {
"type": "Span",
"start": 67,
"end": 73
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "No EOL",
"span": {
"type": "Span",
"start": 76,
"end": 82
}
}
],
"span": {
"type": "Span",
"start": 76,
"end": 82
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 67,
"end": 82
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 82
}
}

View File

@@ -0,0 +1,37 @@
## Literal text
text-backslash-one = Value with \ a backslash
text-backslash-two = Value with \\ two backslashes
text-backslash-brace = Value with \{placeable}
text-backslash-u = \u0041
text-backslash-backslash-u = \\u0041
## String literals
quote-in-string = {"\""}
backslash-in-string = {"\\"}
# ERROR Mismatched quote
mismatched-quote = {"\\""}
# ERROR Unknown escape
unknown-escape = {"\x"}
# ERROR Multiline literal
invalid-multiline-literal = {"
"}
## Unicode escapes
string-unicode-4digits = {"\u0041"}
escape-unicode-4digits = {"\\u0041"}
string-unicode-6digits = {"\U01F602"}
escape-unicode-6digits = {"\\U01F602"}
# OK The trailing "00" is part of the literal value.
string-too-many-4digits = {"\u004100"}
# OK The trailing "00" is part of the literal value.
string-too-many-6digits = {"\U01F60200"}
# ERROR Too few hex digits after \u.
string-too-few-4digits = {"\u41"}
# ERROR Too few hex digits after \U.
string-too-few-6digits = {"\U1F602"}
## Literal braces
brace-open = An opening {"{"} brace.
brace-close = A closing {"}"} brace.

View File

@@ -0,0 +1,937 @@
{
"type": "Resource",
"body": [
{
"content": "Literal text",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 0,
"end": 15
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "text-backslash-one",
"span": {
"type": "Span",
"start": 16,
"end": 34
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value with \\ a backslash",
"span": {
"type": "Span",
"start": 37,
"end": 61
}
}
],
"span": {
"type": "Span",
"start": 37,
"end": 61
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 16,
"end": 61
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "text-backslash-two",
"span": {
"type": "Span",
"start": 62,
"end": 80
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value with \\\\ two backslashes",
"span": {
"type": "Span",
"start": 83,
"end": 112
}
}
],
"span": {
"type": "Span",
"start": 83,
"end": 112
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 62,
"end": 112
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "text-backslash-brace",
"span": {
"type": "Span",
"start": 113,
"end": 133
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "Value with \\",
"span": {
"type": "Span",
"start": 136,
"end": 148
}
},
{
"type": "Placeable",
"expression": {
"type": "MessageReference",
"id": {
"type": "Identifier",
"name": "placeable",
"span": {
"type": "Span",
"start": 149,
"end": 158
}
},
"attribute": null,
"span": {
"type": "Span",
"start": 149,
"end": 158
}
},
"span": {
"type": "Span",
"start": 148,
"end": 159
}
}
],
"span": {
"type": "Span",
"start": 136,
"end": 159
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 113,
"end": 159
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "text-backslash-u",
"span": {
"type": "Span",
"start": 160,
"end": 176
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "\\u0041",
"span": {
"type": "Span",
"start": 179,
"end": 185
}
}
],
"span": {
"type": "Span",
"start": 179,
"end": 185
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 160,
"end": 185
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "text-backslash-backslash-u",
"span": {
"type": "Span",
"start": 186,
"end": 212
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "\\\\u0041",
"span": {
"type": "Span",
"start": 215,
"end": 222
}
}
],
"span": {
"type": "Span",
"start": 215,
"end": 222
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 186,
"end": 222
}
},
{
"content": "String literals",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 224,
"end": 242
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "quote-in-string",
"span": {
"type": "Span",
"start": 243,
"end": 258
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\\"",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 262,
"end": 266
}
},
"span": {
"type": "Span",
"start": 261,
"end": 267
}
}
],
"span": {
"type": "Span",
"start": 261,
"end": 267
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 243,
"end": 267
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "backslash-in-string",
"span": {
"type": "Span",
"start": 268,
"end": 287
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\\\",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 291,
"end": 295
}
},
"span": {
"type": "Span",
"start": 290,
"end": 296
}
}
],
"span": {
"type": "Span",
"start": 290,
"end": 296
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 268,
"end": 296
}
},
{
"content": "ERROR Mismatched quote",
"type": "Comment",
"span": {
"type": "Span",
"start": 297,
"end": 321
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0003",
"arguments": [
"}"
],
"message": "Expected token: \"}\"",
"span": {
"type": "Span",
"start": 346,
"end": 346
}
}
],
"content": "mismatched-quote = {\"\\\\\"\"}\n",
"span": {
"type": "Span",
"start": 322,
"end": 349
}
},
{
"content": "ERROR Unknown escape",
"type": "Comment",
"span": {
"type": "Span",
"start": 349,
"end": 371
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0025",
"arguments": [
"x"
],
"message": "Unknown escape sequence: \\x.",
"span": {
"type": "Span",
"start": 392,
"end": 392
}
}
],
"content": "unknown-escape = {\"\\x\"}\n",
"span": {
"type": "Span",
"start": 372,
"end": 396
}
},
{
"content": "ERROR Multiline literal",
"type": "Comment",
"span": {
"type": "Span",
"start": 396,
"end": 421
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0020",
"arguments": [],
"message": "Unterminated string expression",
"span": {
"type": "Span",
"start": 452,
"end": 452
}
}
],
"content": "invalid-multiline-literal = {\"\n \"}\n\n",
"span": {
"type": "Span",
"start": 422,
"end": 458
}
},
{
"content": "Unicode escapes",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 458,
"end": 476
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "string-unicode-4digits",
"span": {
"type": "Span",
"start": 477,
"end": 499
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\u0041",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 503,
"end": 511
}
},
"span": {
"type": "Span",
"start": 502,
"end": 512
}
}
],
"span": {
"type": "Span",
"start": 502,
"end": 512
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 477,
"end": 512
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "escape-unicode-4digits",
"span": {
"type": "Span",
"start": 513,
"end": 535
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\\\u0041",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 539,
"end": 548
}
},
"span": {
"type": "Span",
"start": 538,
"end": 549
}
}
],
"span": {
"type": "Span",
"start": 538,
"end": 549
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 513,
"end": 549
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "string-unicode-6digits",
"span": {
"type": "Span",
"start": 550,
"end": 572
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\U01F602",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 576,
"end": 586
}
},
"span": {
"type": "Span",
"start": 575,
"end": 587
}
}
],
"span": {
"type": "Span",
"start": 575,
"end": 587
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 550,
"end": 587
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "escape-unicode-6digits",
"span": {
"type": "Span",
"start": 588,
"end": 610
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\\\U01F602",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 614,
"end": 625
}
},
"span": {
"type": "Span",
"start": 613,
"end": 626
}
}
],
"span": {
"type": "Span",
"start": 613,
"end": 626
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 588,
"end": 626
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "string-too-many-4digits",
"span": {
"type": "Span",
"start": 681,
"end": 704
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\u004100",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 708,
"end": 718
}
},
"span": {
"type": "Span",
"start": 707,
"end": 719
}
}
],
"span": {
"type": "Span",
"start": 707,
"end": 719
}
},
"attributes": [],
"comment": {
"content": "OK The trailing \"00\" is part of the literal value.",
"type": "Comment",
"span": {
"type": "Span",
"start": 628,
"end": 680
}
},
"span": {
"type": "Span",
"start": 628,
"end": 719
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "string-too-many-6digits",
"span": {
"type": "Span",
"start": 773,
"end": 796
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "Placeable",
"expression": {
"value": "\\U01F60200",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 800,
"end": 812
}
},
"span": {
"type": "Span",
"start": 799,
"end": 813
}
}
],
"span": {
"type": "Span",
"start": 799,
"end": 813
}
},
"attributes": [],
"comment": {
"content": "OK The trailing \"00\" is part of the literal value.",
"type": "Comment",
"span": {
"type": "Span",
"start": 720,
"end": 772
}
},
"span": {
"type": "Span",
"start": 720,
"end": 813
}
},
{
"content": "ERROR Too few hex digits after \\u.",
"type": "Comment",
"span": {
"type": "Span",
"start": 815,
"end": 851
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0026",
"arguments": [
"\\u41\""
],
"message": "Invalid Unicode escape sequence: \\u41\".",
"span": {
"type": "Span",
"start": 883,
"end": 883
}
}
],
"content": "string-too-few-4digits = {\"\\u41\"}\n",
"span": {
"type": "Span",
"start": 852,
"end": 886
}
},
{
"content": "ERROR Too few hex digits after \\U.",
"type": "Comment",
"span": {
"type": "Span",
"start": 886,
"end": 922
}
},
{
"type": "Junk",
"annotations": [
{
"type": "Annotation",
"code": "E0026",
"arguments": [
"\\U1F602\""
],
"message": "Invalid Unicode escape sequence: \\U1F602\".",
"span": {
"type": "Span",
"start": 957,
"end": 957
}
}
],
"content": "string-too-few-6digits = {\"\\U1F602\"}\n\n",
"span": {
"type": "Span",
"start": 923,
"end": 961
}
},
{
"content": "Literal braces",
"type": "GroupComment",
"span": {
"type": "Span",
"start": 961,
"end": 978
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "brace-open",
"span": {
"type": "Span",
"start": 979,
"end": 989
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "An opening ",
"span": {
"type": "Span",
"start": 992,
"end": 1003
}
},
{
"type": "Placeable",
"expression": {
"value": "{",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 1004,
"end": 1007
}
},
"span": {
"type": "Span",
"start": 1003,
"end": 1008
}
},
{
"type": "TextElement",
"value": " brace.",
"span": {
"type": "Span",
"start": 1008,
"end": 1015
}
}
],
"span": {
"type": "Span",
"start": 992,
"end": 1015
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 979,
"end": 1015
}
},
{
"type": "Message",
"id": {
"type": "Identifier",
"name": "brace-close",
"span": {
"type": "Span",
"start": 1016,
"end": 1027
}
},
"value": {
"type": "Pattern",
"elements": [
{
"type": "TextElement",
"value": "A closing ",
"span": {
"type": "Span",
"start": 1030,
"end": 1040
}
},
{
"type": "Placeable",
"expression": {
"value": "}",
"type": "StringLiteral",
"span": {
"type": "Span",
"start": 1041,
"end": 1044
}
},
"span": {
"type": "Span",
"start": 1040,
"end": 1045
}
},
{
"type": "TextElement",
"value": " brace.",
"span": {
"type": "Span",
"start": 1045,
"end": 1052
}
}
],
"span": {
"type": "Span",
"start": 1030,
"end": 1052
}
},
"attributes": [],
"comment": null,
"span": {
"type": "Span",
"start": 1016,
"end": 1052
}
}
],
"span": {
"type": "Span",
"start": 0,
"end": 1053
}
}

Some files were not shown because too many files have changed in this diff Show More