Skip to content

Commit 30ae1ed

Browse files
Bo Pengmarijnh
authored andcommitted
[python mode] Highlight expressions nested inside format strings
1 parent 899d6a3 commit 30ae1ed

3 files changed

Lines changed: 96 additions & 4 deletions

File tree

mode/python/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ <h2>Python mode</h2>
126126
def __init__(self, mixin = 'Hello'):
127127
self.mixin = mixin
128128

129+
# Python 3.6 f-strings (https://www.python.org/dev/peps/pep-0498/)
130+
f'My name is {name}, my age next year is {age+1}, my anniversary is {anniversary:%A, %B %d, %Y}.'
131+
f'He said his name is {name!r}.'
132+
f"""He said his name is {name!r}."""
133+
f'{"quoted string"}'
134+
f'{{ {4*10} }}'
135+
f'This is an error }'
136+
f'This is ok }}'
137+
fr'x={4*10}\n'
129138
</textarea></div>
130139

131140

mode/python/python.js

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
var identifiers = parserConf.identifiers|| /^[_A-Za-z\u00A1-\uFFFF][_A-Za-z0-9\u00A1-\uFFFF]*/;
6363
myKeywords = myKeywords.concat(["nonlocal", "False", "True", "None", "async", "await"]);
6464
myBuiltins = myBuiltins.concat(["ascii", "bytes", "exec", "print"]);
65-
var stringPrefixes = new RegExp("^(([rbuf]|(br))?('{3}|\"{3}|['\"]))", "i");
65+
var stringPrefixes = new RegExp("^(([rbuf]|(br)|(fr))?('{3}|\"{3}|['\"]))", "i");
6666
} else {
6767
var identifiers = parserConf.identifiers|| /^[_A-Za-z][_A-Za-z0-9]*/;
6868
myKeywords = myKeywords.concat(["exec", "print"]);
@@ -142,8 +142,18 @@
142142

143143
// Handle Strings
144144
if (stream.match(stringPrefixes)) {
145-
state.tokenize = tokenStringFactory(stream.current());
146-
return state.tokenize(stream, state);
145+
var isFmtString = stream.current().toLowerCase().indexOf('f') !== -1;
146+
if (!isFmtString || state.fstr_state !== null) {
147+
// if this is a nested format string (e.g. f' { f"{10*10}" + "a" }' )
148+
// we do not format the nested expression and treat the nested format
149+
// string as regular string
150+
state.tokenize = tokenStringFactory(stream.current());
151+
return state.tokenize(stream, state);
152+
} else {
153+
// need to do something more sophisticated
154+
state.tokenize = formatStringFactory(stream.current());
155+
return state.tokenize(stream, state);
156+
}
147157
}
148158

149159
for (var i = 0; i < operators.length; i++)
@@ -174,6 +184,76 @@
174184
return ERRORCLASS;
175185
}
176186

187+
function formatStringFactory(delimiter) {
188+
while ("rubf".indexOf(delimiter.charAt(0).toLowerCase()) >= 0)
189+
delimiter = delimiter.substr(1);
190+
191+
var singleline = delimiter.length == 1;
192+
var OUTCLASS = "string";
193+
194+
function tokenString(stream, state) {
195+
if (state.fstr_state) {
196+
// inside f-str Expression
197+
if (stream.match(delimiter)) {
198+
// expression ends pre-maturally, but very common in editing
199+
// Could show error to remind users to close brace here
200+
state.fstr_state = null;
201+
return OUTCLASS;
202+
} else if (stream.match('{')) {
203+
// starting brace, if not eaten below
204+
return "punctuation";
205+
} else if (stream.match('}')) {
206+
// return to regular inside string state
207+
state.fstr_state = null;
208+
return "punctuation";
209+
} else {
210+
// use tokenBaseInner to parse the expression
211+
return tokenBaseInner(stream, state.fstr_state);
212+
}
213+
}
214+
while (!stream.eol()) {
215+
stream.eatWhile(/[^'"\{\}\\]/);
216+
if (stream.eat("\\")) {
217+
stream.next();
218+
if (singleline && stream.eol())
219+
return OUTCLASS;
220+
} else if (stream.match(delimiter)) {
221+
state.tokenize = tokenBase;
222+
return OUTCLASS;
223+
} else if (stream.match('{{')) {
224+
// ignore {{ in f-str
225+
return OUTCLASS;
226+
} else if (stream.match('{', false)) {
227+
// switch to nested mode
228+
state.fstr_state = {};
229+
if (stream.current()) {
230+
return OUTCLASS;
231+
} else {
232+
// need to return something, so eat the starting {
233+
stream.next();
234+
return "punctuation";
235+
}
236+
} else if (stream.match('}}')) {
237+
return OUTCLASS;
238+
} else if (stream.match('}')) {
239+
// single } in f-string is an error
240+
return ERRORCLASS;
241+
} else {
242+
stream.eat(/['"]/);
243+
}
244+
}
245+
if (singleline) {
246+
if (parserConf.singleLineStringErrors)
247+
return ERRORCLASS;
248+
else
249+
state.tokenize = tokenBase;
250+
}
251+
return OUTCLASS;
252+
}
253+
tokenString.isString = true;
254+
return tokenString;
255+
}
256+
177257
function tokenStringFactory(delimiter) {
178258
while ("rubf".indexOf(delimiter.charAt(0).toLowerCase()) >= 0)
179259
delimiter = delimiter.substr(1);
@@ -278,6 +358,7 @@
278358
return {
279359
tokenize: tokenBase,
280360
scopes: [{offset: basecolumn || 0, type: "py", align: null}],
361+
fstr_state: null,
281362
indent: basecolumn || 0,
282363
lastToken: null,
283364
lambda: false,

mode/python/test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
MT("before_equal_sign_" + c, "[variable a] [operator " + c + "=] [variable b]");
3131
}
3232

33-
MT("fValidStringPrefix", "[string f'this is a {formatted} string']");
33+
MT("fValidStringPrefix", "[string f'this is a]{[variable formatted]}[string string']");
34+
MT("fValidExpressioninFString", "[string f'expression ]{[number 100][operator *][number 5]}[string string']");
35+
MT("fInvalidFString", "[error f'this is wrong}]");
3436
MT("uValidStringPrefix", "[string u'this is an unicode string']");
3537
})();

0 commit comments

Comments
 (0)