Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 200 additions & 56 deletions src/main/java/leekscript/compiler/LexicalParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -157,62 +157,7 @@ public LexicalParserTokenStream parse(ErrorReporter error) {
long t0 = IACompiler.PHASE_TIMINGS_ENABLED ? System.nanoTime() : 0L;
stream = new CharStream(aiFile.getCode());
while (stream.index < stream.content.length()) {
char c = stream.c;

// Fast paths sur les chars dominants (whitespace + lettres ascii). Le
// dispatch séquentiel d'avant essayait chaque tryParseXxx en série :
// 9 méthodes par token au pire, alors qu'une seule peut réussir.
if (c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == 160) {
stream.next();
continue;
}
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
if (tryParseIdentifier()) continue;
}
if (c >= '0' && c <= '9') {
if (tryParseNumber(error)) continue;
}
if (c == '"' || c == '\'') {
if (tryParseString(error)) continue;
}
if (c == '/') {
// peek(1) pour distinguer commentaire de division
char c2 = stream.index + 1 < stream.content.length() ? stream.content.charAt(stream.index + 1) : 0;
if (c2 == '/' || c2 == '*') {
if (tryParseComments()) continue;
}
if (tryParseOperator()) continue;
}
// Opérateurs (premiers chars)
if (isOperatorStart(c)) {
if (tryParseOperator()) continue;
}
// Brackets / accolades / parenthèses : dispatch direct par char.
// Évite les ~6 tryParseExact séquentiels de tryParseBracketLike, chacun
// avec un charEquals + une string "" + char allocation.
TokenType bracketType = null;
switch (c) {
case '(': bracketType = TokenType.PAR_LEFT; break;
case ')': bracketType = TokenType.PAR_RIGHT; break;
case '[': bracketType = TokenType.BRACKET_LEFT; break;
case ']': bracketType = TokenType.BRACKET_RIGHT; break;
case '{': bracketType = TokenType.ACCOLADE_LEFT; break;
case '}': bracketType = TokenType.ACCOLADE_RIGHT; break;
case ',': bracketType = TokenType.VIRG; break;
case ';': bracketType = TokenType.END_INSTRUCTION; break;
default: break;
}
if (bracketType != null) {
stream.next();
addToken(SINGLE_CHAR_WORDS[c], bracketType);
continue;
}
// Identifiants étendus (lettres accentuées) + lemniscate / pi
if (tryParseSpecialIdentifier()) continue;
if (tryParseIdentifier()) continue;

error.report(new AnalyzeError(new Location(aiFile, stream.getLineCounter(), stream.getCharCounter()), AnalyzeErrorLevel.ERROR, Error.INVALID_CHAR));
stream.next();
step(error);
}
var result = new LexicalParserTokenStream(tokens, new Token(TokenType.END_OF_FILE, "", new Location(aiFile, stream.getLineCounter(), stream.getCharCounter())), computeMatchingBrackets(tokens));
if (IACompiler.PHASE_TIMINGS_ENABLED) {
Expand All @@ -221,6 +166,72 @@ public LexicalParserTokenStream parse(ErrorReporter error) {
return result;
}

/**
* Tokenise un seul élément à la position courante du stream (whitespace, mot,
* nombre, string, opérateur, bracket…). Extrait de la boucle principale pour
* pouvoir être réutilisé par l'interpolation de chaînes ({@link #lexInterpolationExpression}),
* qui doit lexer une expression LeekScript arbitraire à l'intérieur d'un {@code ${ … }}.
*/
private void step(ErrorReporter error) {
if (stream.index >= stream.content.length()) return;
char c = stream.c;

// Fast paths sur les chars dominants (whitespace + lettres ascii). Le
// dispatch séquentiel d'avant essayait chaque tryParseXxx en série :
// 9 méthodes par token au pire, alors qu'une seule peut réussir.
if (c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == 160) {
stream.next();
return;
}
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
if (tryParseIdentifier()) return;
}
if (c >= '0' && c <= '9') {
if (tryParseNumber(error)) return;
}
if (c == '"' || c == '\'') {
if (tryParseString(error)) return;
}
if (c == '/') {
// peek(1) pour distinguer commentaire de division
char c2 = stream.index + 1 < stream.content.length() ? stream.content.charAt(stream.index + 1) : 0;
if (c2 == '/' || c2 == '*') {
if (tryParseComments()) return;
}
if (tryParseOperator()) return;
}
// Opérateurs (premiers chars)
if (isOperatorStart(c)) {
if (tryParseOperator()) return;
}
// Brackets / accolades / parenthèses : dispatch direct par char.
// Évite les ~6 tryParseExact séquentiels de tryParseBracketLike, chacun
// avec un charEquals + une string "" + char allocation.
TokenType bracketType = null;
switch (c) {
case '(': bracketType = TokenType.PAR_LEFT; break;
case ')': bracketType = TokenType.PAR_RIGHT; break;
case '[': bracketType = TokenType.BRACKET_LEFT; break;
case ']': bracketType = TokenType.BRACKET_RIGHT; break;
case '{': bracketType = TokenType.ACCOLADE_LEFT; break;
case '}': bracketType = TokenType.ACCOLADE_RIGHT; break;
case ',': bracketType = TokenType.VIRG; break;
case ';': bracketType = TokenType.END_INSTRUCTION; break;
default: break;
}
if (bracketType != null) {
stream.next();
addToken(SINGLE_CHAR_WORDS[c], bracketType);
return;
}
// Identifiants étendus (lettres accentuées) + lemniscate / pi
if (tryParseSpecialIdentifier()) return;
if (tryParseIdentifier()) return;

error.report(new AnalyzeError(new Location(aiFile, stream.getLineCounter(), stream.getCharCounter()), AnalyzeErrorLevel.ERROR, Error.INVALID_CHAR));
stream.next();
}

/**
* Pré-calcule en O(n) les paires de brackets matchantes ({↔}, [↔], (↔)).
* Permet au syntax pass de skipper un body en O(1) au lieu de re-compter la
Expand Down Expand Up @@ -526,6 +537,16 @@ private boolean tryParseString(ErrorReporter error) {
if (start >= len) return false;
char openQuote = content.charAt(start);
if (openQuote != '"' && openQuote != '\'') return false;

// Interpolation de chaînes (sucre syntaxique au-dessus de la concaténation) :
// uniquement en v4+ et pour les chaînes à guillemets doubles. On ne bascule
// sur le chemin desugaré que si un `${` non échappé est réellement présent,
// sinon on garde le chemin rapide (un seul token VAR_STRING) — comportement
// inchangé pour les chaînes ordinaires.
if (version >= 4 && openQuote == '"' && hasInterpolation(content, start, len)) {
return parseInterpolatedString(error, start);
}

// Scan direct ; chaque \n rencontré incrémente lineCounter (les strings
// peuvent contenir des newlines). À la fin : bump charCounter selon la
// distance depuis le dernier \n.
Expand Down Expand Up @@ -568,6 +589,129 @@ private boolean tryParseString(ErrorReporter error) {
}
}

/**
* Détecte la présence d'un `${` non échappé dans la chaîne démarrant à `start`
* (guillemet ouvrant inclus). Ne modifie pas le stream — simple lookahead pour
* décider entre le chemin rapide et le chemin interpolé.
*/
private static boolean hasInterpolation(String content, int start, int len) {
boolean escaped = false;
for (int i = start + 1; i < len; i++) {
char c = content.charAt(i);
if (c == '\\') { escaped = !escaped; continue; }
if (c == '"' && !escaped) return false; // guillemet fermant, rien trouvé
if (!escaped && c == '$' && i + 1 < len && content.charAt(i + 1) == '{') return true;
escaped = false;
}
return false; // chaîne non fermée : on laisse le chemin normal reporter l'erreur
}

/**
* Desugare une chaîne interpolée en la suite de tokens d'une concaténation :
* {@code "a${x}b"} devient {@code ( "a" + ( x ) + "b" )}. Le parser et le
* générateur de code restent inchangés ; le Java produit est strictement
* identique à une concaténation écrite à la main, donc le coût en opérations
* est le même.
*
* Le premier littéral est toujours émis (même vide) pour ancrer le type String,
* de sorte que {@code "${x}"} avec x entier produise bien une chaîne. Les
* littéraux vides suivants sont omis pour ne pas ajouter de concaténation inutile.
*/
private boolean parseInterpolatedString(ErrorReporter error, int start) {
String content = stream.content;
int len = content.length();
stream.next(); // consomme le guillemet ouvrant
addToken("(", TokenType.PAR_LEFT);
boolean needPlus = false; // émettre un `+` avant le prochain élément
boolean firstLiteral = true; // le premier littéral est toujours émis (ancre le type String)
int chunkStart = stream.index;

while (stream.index < len) {
char c = stream.c;
if (c == '\\') {
// Échappement : le backslash et le char suivant restent dans le littéral
stream.next();
if (stream.index < len) stream.next();
continue;
}
if (c == '"' || (c == '$' && stream.peek(1) == '{')) {
// Flush du littéral courant [chunkStart, index)
int chunkEnd = stream.index;
boolean empty = chunkEnd == chunkStart;
if (!empty || firstLiteral) {
if (needPlus) addToken("+", TokenType.OPERATOR);
addToken("\"" + content.substring(chunkStart, chunkEnd) + "\"", TokenType.VAR_STRING);
needPlus = true;
}
firstLiteral = false;

if (c == '"') {
stream.next(); // consomme le guillemet fermant
addToken(")", TokenType.PAR_RIGHT);
return true;
}
// Interpolation ${ … }
stream.next(); // '$'
stream.next(); // '{'
if (needPlus) addToken("+", TokenType.OPERATOR);
addToken("(", TokenType.PAR_LEFT);
lexInterpolationExpression(error);
addToken(")", TokenType.PAR_RIGHT);
needPlus = true;
chunkStart = stream.index;
continue;
}
stream.next(); // CharStream.next() gère le compteur de lignes pour les \n
}
// Chaîne non fermée
error.report(new AnalyzeError(new Location(aiFile, stream.getLineCounter(), stream.getCharCounter()), AnalyzeErrorLevel.ERROR, Error.STRING_NOT_CLOSED));
addToken(")", TokenType.PAR_RIGHT);
return true;
}

/**
* Lexe l'expression LeekScript à l'intérieur d'un `${ … }`, jusqu'à l'accolade
* fermante correspondante (exclue de l'émission). Réutilise {@link #step} pour
* tokeniser n'importe quelle expression ; la profondeur d'accolades est suivie
* via les tokens émis, de sorte que les `}` à l'intérieur d'une chaîne imbriquée
* ou d'un littéral map ne ferment pas l'interpolation.
*/
private void lexInterpolationExpression(ErrorReporter error) {
int len = stream.content.length();
int depth = 0;
boolean emittedExpr = false;
while (stream.index < len) {
int before = tokens.size();
int idxBefore = stream.index;
step(error);
if (tokens.size() > before) {
Token last = tokens.get(tokens.size() - 1);
TokenType t = last.getType();
if (t == TokenType.ACCOLADE_LEFT) {
depth++;
emittedExpr = true;
} else if (t == TokenType.ACCOLADE_RIGHT) {
if (depth == 0) {
// Accolade fermante de l'interpolation : c'est un délimiteur,
// pas du code → on la retire des tokens.
tokens.remove(tokens.size() - 1);
if (!emittedExpr) {
error.report(new AnalyzeError(new Location(aiFile, stream.getLineCounter(), stream.getCharCounter()), AnalyzeErrorLevel.ERROR, Error.STRING_NOT_CLOSED));
}
return;
}
depth--;
} else {
emittedExpr = true;
}
} else if (stream.index == idxBefore) {
break; // aucun progrès (EOF / char invalide) : éviter une boucle infinie
}
}
// `${` non fermé avant la fin du fichier
error.report(new AnalyzeError(new Location(aiFile, stream.getLineCounter(), stream.getCharCounter()), AnalyzeErrorLevel.ERROR, Error.STRING_NOT_CLOSED));
}

private boolean tryParseComments() {
if (stream.peek() == '/' && stream.peek(1) == '/') {
while (stream.hasMore() && stream.peek() != '\n') stream.next();
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/leekscript/compiler/expression/LeekString.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public void writeJavaCode(MainLeekBlock mainblock, JavaWriter writer, boolean pa
String str = "";
int len = mString.length() - 1;
boolean v2plus = mainblock.getCompiler().getCurrentAI().getVersion() >= 2;
boolean v4plus = mainblock.getCompiler().getCurrentAI().getVersion() >= 4;
for (int i = 0; i < mString.length(); i++) {
if (mString.charAt(i) == '\n') str += "\\n";
else if (mString.charAt(i) == '"') str += "\\\"";
Expand All @@ -61,6 +62,11 @@ else if (v2plus && len > i && mString.charAt(i + 1) == '"') {
str += "\\\"";
i++;
}
// v4+ : `\$` échappe l'interpolation — on émet un `$` littéral.
else if (v4plus && len > i && mString.charAt(i + 1) == '$') {
str += "$";
i++;
}
else {
if (v2plus) {
if (len > i && mString.charAt(i + 1) == '\\') {
Expand Down
35 changes: 35 additions & 0 deletions src/test/java/test/TestString.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,41 @@ public void testOperator_Plus() throws Exception {
code("return 1 + \", \" + 2").equals("\"1, 2\"");
}

@Test
public void testString_interpolation() throws Exception {
// String interpolation (v4+) : sucre syntaxique au-dessus de la concaténation.
// "a${expr}b" est desugaré en ("a" + (expr) + "b") au niveau du lexer, donc
// le code Java généré (et le coût en opérations) est identique à une
// concaténation écrite à la main.
section("String interpolation");
code_v4_("var name = \"Bob\" return \"Hello ${name}!\"").equals("\"Hello Bob!\"");
code_v4_("return \"${1 + 2}\"").equals("\"3\"");
code_v4_("return \"${42}\"").equals("\"42\""); // un entier interpolé devient une chaîne
code_v4_("var a = 2 var b = 3 return \"${a}+${b}=${a + b}\"").equals("\"2+3=5\"");
code_v4_("var x = 5 return \"x=${x}.\"").equals("\"x=5.\"");
code_v4_("var arr = [1, 2, 3] return \"arr=${arr}, len=${count(arr)}\"").equals("\"arr=[1, 2, 3], len=3\"");
// Interpolation imbriquée
code_v4_("return \"out ${ \"in ${1 + 1}\" }!\"").equals("\"out in 2!\"");
// Une chaîne sans ${ reste un littéral simple
code_v4_("return \"no interpolation\"").equals("\"no interpolation\"");
// Coût identique à la concaténation manuelle
code_v4_("var n = \"Bob\" return \"Hi ${n}!\" == \"Hi \" + n + \"!\"").equals("true");
}

@Test
public void testString_interpolation_escaping() throws Exception {
section("String interpolation escaping");
// \$ échappe l'interpolation et produit un $ littéral
code_v4_("var n = 1 return \"\\${n}=${n}\"").equals("\"${n}=1\"");
code_v4_("return \"cost \\$5\"").equals("\"cost $5\"");
// Un $ seul (non suivi de {) reste littéral
code_v4_("return \"price: 5$\"").equals("\"price: 5$\"");
// Les chaînes à guillemets simples ne sont jamais interpolées
code_v4_("return '${1 + 1}'").equals("\"${1 + 1}\"");
// Avant la v4, ${ } est un littéral (pas d'interpolation)
code_v1_3("return \"a${1}b\"").equals("\"a${1}b\"");
}

@Test
public void testString_charAt() throws Exception {
section("String.charAt()");
Expand Down
Loading