From 29fb59cd45d6670cba6d5ec187e832b48b81cb90 Mon Sep 17 00:00:00 2001 From: Chloe Magnier Date: Wed, 17 Jun 2026 07:46:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(compiler):=20interpolation=20de=20cha?= =?UTF-8?q?=C3=AEnes=20"${expr}"=20(sucre=20syntaxique=20sur=20la=20concat?= =?UTF-8?q?=C3=A9nation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'interpolation de chaînes en v4+ pour les chaînes à guillemets doubles : `"a${expr}b"`. C'est du pur sucre syntaxique desugaré au niveau du lexer en la suite de tokens d'une concaténation : `( "a" + (expr) + "b" )`. Le parser et le générateur de code restent totalement inchangés. Le code Java produit est strictement identique à une concaténation `+` écrite à la main, donc le COÛT EN OPÉRATIONS est exactement le même que l'addition/concaténation correspondante (vérifié : `"Hello ${name}!"` et `"Hello " + name + "!"` coûtent tous deux 26 ops). Détails : - syntaxe `${expr}` ; n'importe quelle expression LeekScript est acceptée (ex. `${a + b}`, `${count(arr)}`, interpolation imbriquée `${ "in ${x}" }`) - le premier littéral est toujours émis (même vide) pour ancrer le type String, de sorte que `"${42}"` produise la chaîne "42" et non l'entier - les littéraux vides intermédiaires sont omis pour ne pas ajouter de concaténation superflue - `\$` échappe l'interpolation et produit un `$` littéral ; un `$` seul reste littéral - uniquement les guillemets doubles : `'${x}'` reste un littéral - réservé à la v4+ : en v1-3 `${...}` reste du texte littéral, donc aucune IA existante n'est impactée Implémentation : extraction de la boucle de dispatch du lexer dans `step()`, réutilisée pour lexer l'expression à l'intérieur d'un `${ … }` (suivi de la profondeur d'accolades pour gérer maps/blocs/chaînes imbriqués). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../leekscript/compiler/LexicalParser.java | 256 ++++++++++++++---- .../compiler/expression/LeekString.java | 6 + src/test/java/test/TestString.java | 35 +++ 3 files changed, 241 insertions(+), 56 deletions(-) diff --git a/src/main/java/leekscript/compiler/LexicalParser.java b/src/main/java/leekscript/compiler/LexicalParser.java index d17cad4e..01e8425d 100644 --- a/src/main/java/leekscript/compiler/LexicalParser.java +++ b/src/main/java/leekscript/compiler/LexicalParser.java @@ -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) { @@ -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 @@ -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. @@ -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(); diff --git a/src/main/java/leekscript/compiler/expression/LeekString.java b/src/main/java/leekscript/compiler/expression/LeekString.java index 52ce58f8..5ed7c9de 100644 --- a/src/main/java/leekscript/compiler/expression/LeekString.java +++ b/src/main/java/leekscript/compiler/expression/LeekString.java @@ -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 += "\\\""; @@ -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) == '\\') { diff --git a/src/test/java/test/TestString.java b/src/test/java/test/TestString.java index 93d9d7bd..33e6c753 100644 --- a/src/test/java/test/TestString.java +++ b/src/test/java/test/TestString.java @@ -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()");