Skip to content

Commit e730afa

Browse files
committed
Document deferred nushell raw strings and multi-rune escapes
Add detailed implementation plans for two deferred features that require multi-rune lookahead in the tokenizer state machine: raw strings (r#'...'#) and hex/unicode escapes (\xHH, \u{X...}). Each plan includes current vs expected behavior, how nushell's own lexer handles it, affected files, key challenges, recommended approach, and test cases. Assisted-by: Crush:glm-5.2
1 parent 563c5f8 commit e730afa

2 files changed

Lines changed: 183 additions & 5 deletions

File tree

format_nushell.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import "strings"
77
// - Backtick (`) is a quote character (not an escape like PowerShell)
88
// - $'...' and $"..." are interpolated strings ($ prefix + standard quote)
99
// - C-style escapes in double quotes with a richer set than bash:
10-
// \" \' \\ \/ \b \f \r \n \t \0 \a \e \( \) \{ \} \$ \^ \# \| \~ \xHH \u{X}
10+
// \" \' \\ \/ \b \f \r \n \t \0 \a \e \( \) \{ \} \$ \^ \# \| \~
11+
// \xHH and \u{X...} are deferred (see format-nushell.md → Deferred Features)
1112
// - No POSIX list operators (no &&, ||, &)
1213
// - Stream redirect operators: out>, err>, out+err>, o>, e>, o+e>
1314
// and pipe variants: e>|, err>|, o+e>|, out+err>|
14-
// - r#'...'# raw strings need multi-rune opener support (deferred)
15+
// - r#'...'# raw strings need multi-rune opener support (deferred — see
16+
// format-nushell.md → Deferred Features)
1517
type nushellFormat struct{}
1618

1719
// NushellFormat returns the nushell lexical format.

skills/shlex/references/format-nushell.md

Lines changed: 179 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,189 @@ Nushell passes args **with quoting intact** (unlike bash which strips quotes). T
188188

189189
## Edge Cases
190190

191-
- **`r#'...'#` raw strings**: the `#`-not-a-comment issue. Needs multi-rune opener recognition. Deferred — the `r#` prefix is currently treated as word characters, and `#` inside `r#'...'` is a word character (not a comment) by coincidence because `#` only starts a comment from `START_STATE`.
192191
- **Backtick as quote** (not escape): opposite of PowerShell. Don't confuse the two formats.
193192
- **`$` prefix on quotes**: `$'...'` and `$"..."` — the `$` adjoins the quoted segment; `Words()` merges them.
194193
- **No `COMP_WORDBREAKS`**: nushell has no equivalent env var.
195194
- **C-style escapes produce real characters**: `\n` in `"..."` produces a newline character (0x0A), not the literal text `n`. Implemented via `EscapingQuoteUnescaper`.
196-
- **Stream redirect operators**: `out>`, `err>`, `o+e>`, `e>|`, etc. are multi-rune operators. Implemented via `PostProcess`.
197-
- **`\xHH` and `\u{X}` escapes**: deferred — require multi-rune lookahead in the escape state.
195+
- **Stream redirect operators**: `out>`, `err>`, `o+e>`, `e>|`, etc. are multi-rune operators. Implemented via `PostProcess`. Only bare words are merged — quoted strings like `'out'` are not treated as stream operators.
196+
- **Quoted words before `>`**: `'out'>bar` is a string literal `out` followed by a plain redirect `>`, not a stream redirect operator. The `PostProcess` check `t.Value == t.RawValue` prevents merging quoted words.
197+
198+
## Deferred Features
199+
200+
Two features require multi-rune lookahead in the tokenizer's core state machine and are documented here for future implementation.
201+
202+
### 1. Raw Strings `r#'...'#`
203+
204+
#### What they are
205+
206+
Nushell raw strings are delimited by `r#`...`'#` (or `r##`...`'##`, `r###`...`'###`, etc.). They behave like single-quoted strings (no escapes) but can contain single quotes. The number of `#` symbols in the opener and closer must match.
207+
208+
```nu
209+
r#'Raw strings can contain 'quoted' text.'# # → Raw strings can contain 'quoted' text.
210+
r##'I can use '#' in a raw string'## # → I can use '#' in a raw string
211+
r#''# # → (empty string)
212+
```
213+
214+
#### Current (wrong) behavior
215+
216+
The tokenizer treats `r` as a regular word character and `#` as either a word character (inside `IN_WORD_STATE`) or a comment (from `START_STATE`). So `r#'hello'#` is tokenized as:
217+
218+
- `r` → word char (IN_WORD_STATE)
219+
- `#` → word char (not START_STATE, so not a comment — this is the coincidence)
220+
- `'` → enters QUOTING_STATE
221+
- `hello` → literal content
222+
- `'` → exits QUOTING_STATE → IN_WORD_STATE
223+
- `#` → word char
224+
225+
Result: one WORD_TOKEN with `Value="r#hello#"` and `RawValue="r#'hello'#"`. The `r#` prefix and `'#` suffix are included in the value.
226+
227+
Expected: `Value="hello"`, `RawValue="r#'hello'#"`.
228+
229+
#### How nushell's lexer handles it
230+
231+
In nushell's `lex_item` (`crates/nu-parser/src/lex.rs`), the raw-string check happens **before** the comment check:
232+
233+
```rust
234+
} else if c == b'r' && input.get(*curr_offset + 1) == Some(b'#').as_ref() {
235+
let lex_result = lex_raw_string(input, curr_offset, span_offset);
236+
...
237+
}
238+
```
239+
240+
`lex_raw_string` counts consecutive `#` chars after `r` to determine `prefix_sharp_cnt`, expects a `'` after them, then scans forward looking for a closing `'` followed by the same number of `#` chars. The entire `r#'...'#` sequence is consumed as a single `TokenContents::Item`.
241+
242+
#### Implementation plan
243+
244+
The tokenizer's `scanStream` state machine processes one rune at a time via `ReadRune`/`UnreadRune`. To support raw strings, we need multi-rune lookahead in the `START_STATE` and `IN_WORD_STATE` handlers:
245+
246+
1. **Detect the `r#` pattern**: When the current rune is `r` and the state is `START_STATE` or `IN_WORD_STATE`, peek ahead to check if the next rune is `#`. If so, count consecutive `#` runes to determine `prefix_sharp_cnt`.
247+
248+
2. **Validate the opener**: After the `#` sequence, expect a `'`. If not found, treat `r` as a regular word char and `#` as a comment/word char as appropriate.
249+
250+
3. **Scan to the closer**: Read forward until finding a `'` followed by exactly `prefix_sharp_cnt` `#` runes. Everything between the opener `'` and the closer `'#` is the raw string content.
251+
252+
4. **Emit the token**: Set `Token.Value` to the raw content (between opener and closer), `Token.RawValue` to the full `r#'...'#` source text, and `Token.State` to `IN_WORD_STATE` (the state after closing).
253+
254+
5. **Handle EOF in raw string**: If the closer is not found, emit the token with whatever content was consumed and set state to a new `RAW_STRING_STATE` (or reuse `QUOTING_STATE` for completion purposes — an unclosed raw string behaves like an unclosed quote).
255+
256+
**Affected files**:
257+
- `shlex.go``scanStream()` `START_STATE` and `IN_WORD_STATE` handlers: add `r#` detection before the default/wordbreak cases
258+
- `format.go` — possibly a new `Format` interface method like `RawStringOpeners() bool` or add raw-string support to the classifier
259+
- `format_nushell.go` — enable raw string support
260+
261+
**Key challenge**: The state machine is shared across all formats. Raw-string detection must be opt-in per format (only nushell has raw strings). This could be:
262+
- A new `Format` interface method (e.g. `SupportsRawStrings() bool`)
263+
- A new rune class (e.g. `rawStringOpenerRuneClass`) triggered by a multi-rune classifier
264+
- A `PostProcessor` approach — but this won't work because by the time PostProcess runs, the tokens are already split incorrectly (the `#` may have been classified as a comment)
265+
266+
The cleanest approach is likely a multi-rune lookahead in `scanStream`, gated by a format check, similar to how `NonEscapingQuoteEscapes()` gates the single-quote peek logic.
267+
268+
**The `#`-not-a-comment issue**: Currently `#` only starts a comment from `START_STATE` (word boundary). Inside `IN_WORD_STATE`, `#` falls through to the `default` case and is added as a word char. This means `r#'hello'#` does not accidentally trigger a comment. However, `r#` at a word boundary (e.g. after a space) would have `#` classified as `commentRuneClass` from `START_STATE`, which would start a comment instead of a raw string. The fix must check for `r#` **before** the `commentRuneClass` check in `START_STATE`.
269+
270+
**Test cases to add**:
271+
- `r#'hello'#` → value `hello`
272+
- `r#''#` → value `` (empty)
273+
- `r##'contains '#'# here'##` → value `contains '#'# here`
274+
- `r#'unclosed` → state indicates open raw string (for completion)
275+
- `echo r#'hello'#` → words `[echo hello]`
276+
- `r#'it's a test'#` → value `it's a test` (single quotes allowed inside)
277+
278+
### 2. Hex and Unicode Escapes `\xHH` and `\u{X...}`
279+
280+
#### What they are
281+
282+
Inside double-quoted strings (`"..."` and `$"..."`), nushell supports:
283+
284+
| Escape | Format | Result |
285+
|--------|--------|--------|
286+
| `\xHH` | exactly 2 hex digits | single byte (0x00–0xFF) |
287+
| `\u{X...}` | 1-6 hex digits in braces, max 0x10FFFF | Unicode codepoint (UTF-8 encoded) |
288+
289+
```nu
290+
"\x41\x42\x43" # → ABC
291+
"\u{1F600}" # → 😀
292+
"\u{0041}" # → A
293+
$"hello\n($name)" # → hello\nworld (interpolated)
294+
```
295+
296+
#### Current (wrong) behavior
297+
298+
The `EscapingQuoteUnescaper` interface handles single-rune escapes (`\n`, `\t`, etc.) but cannot handle multi-rune escapes like `\x41` or `\u{1F600}` because the `ESCAPING_QUOTED_STATE` handler only receives one rune after the backslash. When it sees `x` or `u`, the unescaper returns `handled=false`, so both `\` and `x` (or `u`) are kept literally.
299+
300+
Result: `"\x41"` produces value `\x41` instead of `A`.
301+
302+
#### How nushell's parser handles it
303+
304+
In `unescape_string` (`crates/nu-parser/src/parse_literals.rs`):
305+
306+
```rust
307+
Some(b'x') => {
308+
match parse_hex_escape(bytes, idx, span) { ... } // reads exactly 2 hex digits
309+
}
310+
Some(b'u') => {
311+
match parse_unicode_escape(bytes, idx, span) { ... } // reads {X...}
312+
}
313+
```
314+
315+
`parse_hex_escape` reads exactly 2 hex digits after `\x`. `parse_unicode_escape` reads `{`, then 1-6 hex digits, then `}`, validating the codepoint ≤ 0x10FFFF.
316+
317+
#### Implementation plan
318+
319+
The `EscapingQuoteUnescaper` interface is called from `ESCAPING_QUOTED_STATE` with a single rune. To support multi-rune escapes, we need the unescaper to be able to consume additional runes from the tokenizer. Two approaches:
320+
321+
**Option A: Multi-rune unescaper (preferred)**
322+
323+
Extend `EscapingQuoteUnescaper` with a method that takes a `runeReader` or similar interface, allowing it to consume additional runes:
324+
325+
```go
326+
type EscapingQuoteUnescaper interface {
327+
EscapingQuoteUnescape(r rune) (replacement string, handled bool)
328+
// EscapingQuoteUnescapeMulti is called when EscapingQuoteUnescape returns
329+
// handled=false. It receives the first rune and a peekable reader, allowing
330+
// the format to consume additional runes for multi-rune escapes like \xHH.
331+
// Returns the replacement string and the number of additional runes consumed
332+
// (beyond the first rune). If not handled, returns (0, false).
333+
EscapingQuoteUnescapeMulti(r rune, reader *tokenizer) (replacement string, extraConsumed int, handled bool)
334+
}
335+
```
336+
337+
In the `ESCAPING_QUOTED_STATE` handler:
338+
1. Call `EscapingQuoteUnescape(nextRune)` first — handles single-rune escapes.
339+
2. If not handled, call `EscapingQuoteUnescapeMulti(nextRune, t)` — handles `\xHH` by reading 2 more runes, `\u{...}` by reading until `}`.
340+
3. If still not handled, keep both `\` and the rune literally (current behavior).
341+
342+
The `tokenizer` already has `ReadRune`/`UnreadRune` methods. The multi-rune unescaper would read additional runes directly from the tokenizer, updating `t.index` and `token.RawValue`.
343+
344+
**Option B: Peek-based approach**
345+
346+
Give the unescaper access to a `PeekRune(n int) (rune, bool)` method that peeks ahead without consuming. The unescaper returns how many additional runes to consume. This is cleaner but requires adding a peek buffer to the tokenizer (currently it only has read/unread).
347+
348+
**Option C: State-machine extension**
349+
350+
Add new states like `ESCAPING_HEX_STATE` and `ESCAPING_UNICODE_STATE` to the state machine, with format-gated transitions. This is more invasive but keeps the single-rune-at-a-time model.
351+
352+
**Affected files**:
353+
- `format.go` — extend `EscapingQuoteUnescaper` interface
354+
- `shlex.go``ESCAPING_QUOTED_STATE` handler: add multi-rune escape logic
355+
- `format_nushell.go` — implement `\xHH` and `\u{X...}` in the unescaper
356+
357+
**Key challenges**:
358+
- The tokenizer's `RawValue` must include all consumed runes (the full `\x41` or `\u{1F600}`), while `Value` gets only the replacement character.
359+
- Invalid escapes (`\x4`, `\x4z`, `\u{110000}`, `\u{6e`) are parse errors in nushell. shlex should be lenient: if the hex digits are missing or invalid, keep the backslash and the escape letter literally.
360+
- `EscapingQuoteEscapeChars` (used by fish) and `EscapingQuoteUnescaper` are mutually exclusive — the unescaper takes priority. The multi-rune extension only applies to formats implementing `EscapingQuoteUnescaper`.
361+
362+
**Test cases to add**:
363+
- `"\x41\x42\x43"` → value `ABC`
364+
- `"\x00"` → value `\x00` (NUL)
365+
- `"\xFF"` → value `\xFF` (byte 255)
366+
- `"\u{1F600}"` → value `😀`
367+
- `"\u{0041}"` → value `A`
368+
- `"\u{0}"` → value `\x00` (NUL via unicode)
369+
- `"\x4"` → value `\x4` (incomplete — lenient, keep literal)
370+
- `"\x4z"` → value `\x4z` (invalid hex — lenient, keep literal)
371+
- `"\u{110000}"` → value `\u{110000}` (out of range — lenient, keep literal)
372+
- `"\u{6e"` → value `\u{6e"` (missing `}` — lenient, keep literal, quote stays open)
373+
- `$"\x41"` → value `$A` (interpolated double-quote with hex escape)
198374

199375
## References
200376

0 commit comments

Comments
 (0)