on master help sign in
Allow single-quoted Text literals

Text literals now accept either double or single quotes as the delimiter. The closing quote matches the opening one, so a single-quoted literal may contain unescaped double quotes ('say "hi"'), and all escapes work in both forms. This is purely additive: double-quoted literals behave as before.

commit09c2766e87ab497d8870c456af0f193fd855f32f authoriko <mail@iko.soy> dateWed 16 Sep 2026, 00:51 (4 weeks ago) parent4e1235b
4 files changed+49−9
SPEC.md+3−1 src/lex.rs+9−7 tests/eval.rs+10−1 tests/lex.rs+27
SPEC.md+3−1
@@ -173,7 +173,9 @@ same value if they have the same field names with equal values.
173173- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
174174 usable in expressions as values (§4.12).
175175- **Integer literals.** `[0-9]+`. There is no unary minus; write `0 - 1`.
176−- **Text literals.** `"…"` with escapes `\"`, `\\`, `\n`, `\t`, `\r`.
176+- **Text literals.** `"…"` or `'…'` with escapes `\"`, `\'`, `\\`, `\n`,
177+ `\t`, `\r`. The closing quote matches the opening one, so a single-quoted
178+ literal may contain unescaped `"` and a double-quoted one unescaped `'`.
177179- **Id literals.** `@` immediately followed by one or more of the letters
178180 `k`–`z` (jj's change-id alphabet), e.g. `@wqzt`: an existing id, given in
179181 full or by a unique prefix.
src/lex.rs+9−7
@@ -265,8 +265,9 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
265265 push!(Tok::LabelLit(s), start_line, start_col);
266266 continue;
267267 }
268− // text literal
269− if c == '"' {
268+ // text literal, double- or single-quoted
269+ if c == '"' || c == '\'' {
270+ let quote = c;
270271 i += 1;
271272 col += 1;
272273 let mut s = String::new();
@@ -275,12 +276,12 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
275276 err!("unterminated text literal");
276277 }
277278 let d = chars[i];
279+ if d == quote {
280+ i += 1;
281+ col += 1;
282+ break;
283+ }
278284 match d {
279− '"' => {
280− i += 1;
281− col += 1;
282− break;
283− }
284285 '\\' => {
285286 if i + 1 >= chars.len() {
286287 err!("unterminated text literal");
@@ -288,6 +289,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
288289 let e = chars[i + 1];
289290 let r = match e {
290291 '"' => '"',
292+ '\'' => '\'',
291293 '\\' => '\\',
292294 'n' => '\n',
293295 't' => '\t',
tests/eval.rs+10−1
@@ -227,8 +227,17 @@ fn label_literals_are_revsets() {
227227}
228228
229229#[test]
230−fn path_literals_are_text_lists() {
230+fn single_quoted_text() {
231231 let (mut i, cfg) = make_interp();
232+ check!(i, cfg, "'hello'", Value::text("hello"));
233+ check!(i, cfg, "'say \"hi\"'", Value::text("say \"hi\""));
234+ check!(i, cfg, "\"don't\"", Value::text("don't"));
235+ check!(i, cfg, "'a' ++ 'b'", Value::text("ab"));
236+ check!(i, cfg, "'x' == \"x\"", Value::Bool(true));
237+}
238+
239+#[test]
240+fn path_literals_are_text_lists() { let (mut i, cfg) = make_interp();
232241 check!(
233242 i,
234243 cfg,
tests/lex.rs+27
@@ -71,6 +71,33 @@ fn text_literals_and_escapes() {
7171 assert!(err(r#""\x""#).contains("escape"));
7272}
7373
74+#[test]
75+fn single_quoted_text_literals() {
76+ // single quotes are an alternative delimiter with the same escapes
77+ assert_eq!(toks("'hello'"), vec![Tok::Text("hello".into())]);
78+ // an unescaped " is fine inside single quotes
79+ assert_eq!(toks(r#"'say "hi"'"#), vec![Tok::Text("say \"hi\"".into())]);
80+ // an unescaped ' is fine inside double quotes
81+ assert_eq!(toks("\"don't\""), vec![Tok::Text("don't".into())]);
82+ // \' escapes the single quote
83+ assert_eq!(toks(r"'don\'t'"), vec![Tok::Text("don't".into())]);
84+ // \" works in single-quoted literals too
85+ assert_eq!(toks(r#"'a\"b'"#), vec![Tok::Text("a\"b".into())]);
86+ // other escapes work the same
87+ assert_eq!(toks(r"'\n\t\r\\'"), vec![Tok::Text("\n\t\r\\".into())]);
88+ // empty
89+ assert_eq!(toks("''"), vec![Tok::Text("".into())]);
90+ // errors
91+ assert!(err("'unterminated").contains("unterminated"));
92+ assert!(err("'a\nb'").contains("newline"));
93+ assert!(err(r"'\x'").contains("escape"));
94+ // adjacent strings are two tokens (application), not one string
95+ assert_eq!(
96+ toks("'it' 's'"),
97+ vec![Tok::Text("it".into()), Tok::Text("s".into())]
98+ );
99+}
100+
74101#[test]
75102fn id_literals() {
76103 assert_eq!(toks("@wqzt"), vec![Tok::IdLit("wqzt".into())]);