Add string built-ins for multi-line text formatting: ?indent, ?dedent, ?wrap, ?pad_lines - #130
Add string built-ins for multi-line text formatting: ?indent, ?dedent, ?wrap, ?pad_lines#130gdisirio wants to merge 9 commits into
Conversation
…, ?wrap, ?pad_lines These four built-ins make it easier to format multi-line text, which is useful when generating source code, configuration files, documentation comments, and similar structured output. They all work on the string they're applied to and have no side effects; none require any new configuration or language mode. - ?indent(prefix): prepends prefix to each (non-empty) line. - ?dedent(prefix): removes prefix from the start of each line that has it (the inverse of ?indent). - ?wrap(width, firstPrefix[, restPrefix]): word-wraps the string to the given column width, with configurable per-line prefixes. Handy for wrapped comment blocks. - ?pad_lines(width[, fillChar]): pads each line on the right to the given column. Unlike ?right_pad, which pads the string as a whole, this operates per line, which is useful for aligning multi-line text. Line breaks (LF, CR, CRLF) are recognized and preserved. Added JUnit coverage and FreeMarker Manual reference entries (with @SInCE 2.3.35).
|
I'm primarily commenting just to signtal that I have seen this and will eventually deal with it in depth.
The main doubt there is handling tabs, and even non-breaking-space characters. |
|
Thanks for the read! All three points are fair, here's how I'd address them. On renaming On tabs and non-breaking spaces: widths in all four built-ins are counted in Java Non-breaking spaces are an interesting case: On One related thought, since you brought up the tab handling: it might be useful to have built-ins for normalizing tabs and spaces explicitly. Something like For code generation that's a real need — if the target project uses tabs, the generator needs to emit tabs; if it mandates 4-space indent, ditto. Having primitives for that means generators don't each have to roll their own. Not proposing them in this PR — just floating the idea, since it's the underlying capability you'd want behind the "expand tabs first" docs note. Happy to do a follow-up PR if it sounds worth having. I'll push the revisions for the three current points in a day or two and ping you. Thanks again for engaging on this. |
…dent. - Rename ?pad_lines / ?padLines -> ?right_pad_lines / ?rightPadLines, for consistency with ?right_pad / ?left_pad. The class was renamed to right_pad_linesBI to match the snake_case naming convention used by the surrounding builtins. NUMBER_OF_BIS unchanged (renamed pair). - Add a no-argument form to ?dedent: ?dedent() finds the longest leading whitespace (spaces and tabs) that is a common prefix of all non-empty lines and removes it. Same semantics as Python's textwrap.dedent — robust to imperfect input. Empty/whitespace-only lines are ignored when computing the common prefix. A leading tab and a leading space are distinct (no implicit collapsing), matching Python. - Update tests: rename pad_lines tests; add 8 tests for ?dedent() covering uniform indent, mixed indent, blank lines, no common prefix, tabs, tabs+spaces distinction, empty input, and already-dedented input. - README: rename and document ?right_pad_lines, document the new no-arg ?dedent() with examples, add notes that widths are counted in Java chars (not display columns) and that ?wrap correctly leaves U+00A0 inside words (not used as break points). Full ./gradlew check is green; ChibiOS oop+xhal regenerate with zero diff. Changes parallel what will go on the upstream PR branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with ?right_pad / ?left_pad naming. Internal class renamed to right_pad_linesBI accordingly. Registration moved to the alphabetical position after right_pad. Tests, manual entry, and index link renamed. Per review comment by ddekany on PR apache#130.
…nt-style) Adds a more robust default behaviour for ?dedent. The no-argument form finds the longest leading whitespace (spaces and tabs only) that is a common prefix of every non-empty line, and removes that. This handles imperfect input (lines with different leading-whitespace amounts) gracefully, whereas the original explicit-prefix form leaves any line not starting with the exact prefix unchanged. Semantics match Python's textwrap.dedent. Empty/whitespace-only lines are ignored when computing the common prefix and pass through unchanged. A leading tab and a leading space are distinct (no implicit collapsing), again matching Python. The explicit-prefix form ?dedent(prefix) remains for cases where exact control is wanted; it's not redundant — just less robust by design. 8 new JUnit tests covering uniform indent, mixed indent, blank-line handling, no-common-prefix passthrough, tabs, tabs+spaces distinction, empty input, and already-dedented input. Manual section expanded with the new form and a worked example. Per review comment by ddekany on PR apache#130.
…pace behaviour Adds notes in the manual for the new built-ins: - For ?right_pad_lines: widths are counted in Java chars (UTF-16 code units), not visual display columns — same as ?right_pad / ?left_pad. A tab counts as one character, not as an advance to the next tab stop. Visual alignment for tab-containing input requires expanding tabs first. - For ?wrap: same width semantics, plus a note that words are split on Java's \s+, which does NOT include U+00A0 (non-breaking space) — so a non-breaking space correctly stays inside a word and is never used as a break point. This is the intended behaviour. Per review comment by ddekany on PR apache#130 about tab and non-breaking space handling.
|
Pinging as promised — the revisions are pushed (commits
|
|
Have you signed an ICLA at Apache (see https://www.apache.org/licenses/contributor-agreements.html)? That will be needed. And again, sorry for the slow review... Will try to take a close look this weekned. |
|
One thing I want to change is that currently we have |
- ?dedent() => ?dedent, and ?dedent() is error - ?wrap(width) is now valid (prefix defaults to "") - Reworked Manual sections, especially for ?wrap - Moved string transformation methods from Builtins to _CoreStringUtils - Moved most string transformation testing _CoreStringUtilsTest. Add some more test cases. - IndentAndWrapBuiltInTest now focues on the FTL interface issues, like number and type of arguments. - Adjusted IndentAndWrapBuiltInTest to code use FTL JUnit test conventions in this project
|
I was, perhaps naively, pushed into this branch my adjustments... hoping it will somehow magically do what I meant to do in the PR (i.e. adding a commit to it), but no. Anyway, see if you can bring that into your repo, and move the PR head on it: 4ab4d64 Now, the functional changes I did:
But, I have doubts about the designed (i.e. deliberate) behavior of some of the build-ints. Can you make your case regarding these? Like, show what the practical use-case is, and why this behavior is better than what I think would be more intuitive (we try operate on the principle of least surprise).
Update: Removed concerns regarding |
|
My doubt with the designed behavior of
I have similar issue with Also, when it comes to input line emptiness, especially for source code, you often want to treat white-space-only lines as Lastly, one thing that's surely missing is that |
Without it the rat and ratDistSrc tasks fail with "1 unapproved license", so ./gradlew check doesn't pass.
|
Thanks for doing the work rather than just describing it — the The PR head is now on your commit. One thing to flag, in case it bites you elsewhere: On the four design questions:
|
|
On The generated output looks like this ( #define VAL_GPIOA_MODER (PIN_MODE_INPUT(GPIOA_BUTTON) | \
PIN_MODE_INPUT(GPIOA_PIN1) | \
[...]
PIN_MODE_ALTERNATE(GPIOA_SPI1_SCK) | \
PIN_MODE_ALTERNATE(GPIOA_SPI1_MISO) | \
PIN_MODE_ALTERNATE(GPIOA_SPI1_MOSI) | \
PIN_MODE_INPUT(GPIOA_PIN8) | \A 16-line C macro, one line per GPIO pin, with the trailing That idiom appears 121 times across 19 board templates. Aligning trailing continuations in generated C macros isn't a niche case — it's a large part of what a header generator does, and the same shape shows up for trailing comments and for aligning I should be straight about what this does and doesn't prove, though. Here the lines are produced one at a time inside a Where the multi-line form earns its place is when the block arrives as a single string rather than as a loop: the rendered body returned by a macro, the content of a multi-line literal, or the output of That also sharpens my earlier point about empty lines. In the trailing-comment variant of this pattern you pad a block and then append per line; a blank separator line inside the block, padded to column 76, gives you 76 trailing spaces in generated C. Hence my preference for leaving empty lines untouched by default. One more thing from the same library, relevant to your empty-lines question rather than to So it treats whitespace-only lines as empty ( It also shows one limit worth knowing about: that macro forces lines starting with |
|
Note that I haven't done the proposed behavioral changes. I did some cleanup in my commit, but I half-assumed as PR maker you will want to do the other changes, so, go ahead. |
|
"In those cases the only way to align it today is to split on newlines, loop, pad, and rejoin" - Typically, you shoundn't do the rejoining though. You just print the lines one-by-one inside a |
…ght_pad_lines. ?dedent(prefix) now removes from each line the longest prefix of the parameter that the line actually starts with, instead of only acting on lines that carry the whole prefix. Since ?indent adds the prefix unconditionally, the all-or-nothing behavior could leave a line that was originally the least indented as the most indented one. ?indent(prefix) now adds the prefix to every line, including empty ones, and then right-trims each resulting line. One rule then gives the wanted result in both cases: with a "# " prefix an empty line becomes "#" rather than a line with trailing whitespace, and with a whitespace-only prefix it becomes empty, which is what it already did. An optional 2nd boolean argument switches the trimming off; it defaults to true, so this isn't a behavioral change for whitespace indentation. Lines that contain whitespace only are now treated as empty throughout. Previously the no-argument ?dedent ignored them when computing the common prefix while ?indent treated them as content, so the family disagreed with itself. In generated output such whitespace is an accident of whichever loop emitted it, so behavior shouldn't depend on it. This also matches textwrap.dedent, which normalizes such lines to empty. ?right_pad_lines is removed. Aligning a column of lines is better done by splitting and looping, which keeps all the single-line built-ins available and, unlike a whole-block built-in, lets you append something after the padding of each line -- which the motivating use case (trailing "\" macro continuations) requires. Added getBooleanMethodArg/getOptBooleanMethodArg to BuiltIn, next to the existing string and number variants.
|
Pushed as
Whitespace-only lines are now treated as empty in
You're right about it, and the argument that settled it is the one you made: you don't rejoin, you print inside the Two things I decided while implementing, both of which you may want to look at: I left The no-argument Also added
|
…o be more readable - Javadoc improvements - Some rewording/cleanup in the Manual
|
My new commint in this branch (please integrate into PR as previosuly): 621a8ac This time I have read through the text processing algorithms, and cleaned them up (feelt quite AI-ish at places). There's no functional change. Now, what's left:
|
|
This contribution is AI-assisted so that is not surprising, I hope this was clear from beginning. I will review your points. |
…ent tests. ?dedent(prefix) now removes the trailing whitespace of each resulting line, with an optional 2nd boolean argument to switch that off, defaulting to true, mirroring ?indent. Removing a prefix can leave whitespace behind that only looks like indentation: a line containing whitespace only loses just as much of it as the prefix is long, so with a 4 character prefix a line of 5 spaces kept 1 space. That was also asymmetric with ?indent, which trims, so ?indent(p)?dedent(p) didn't round-trip for such lines. An empty prefix is a no-op for both built-ins, and doesn't trim either. There's nothing to add or remove then, so doing nothing is what's least surprising for something called indent or dedent. _CoreStringUtilsTest is now split into two nested classes: the indent/dedent tests are parametric, running for each line-break type (LF, CRLF, CR) and with and without a line-break at the end of the input, while the wrap tests stay plain, as wrap collapses all whitespace including line-breaks and so can't depend on the line-break type. Test data is written with "\n" and no trailing line-break, and adapted to the parameters. This subsumes the tests that existed only to check a specific line ending or the presence of a trailing line-break, including the one marked "!!T". That's 31 test methods over 6 parameter combinations. All pass, so the line-break handling was already correct for CR and CRLF; the coverage was what was missing.
|
Drafted by Claude (an AI coding assistant) working on this branch with me. The decisions described below — the empty-prefix no-op, the test structure, and dropping Your commit is in, and both points are done — pushed as
The tests are parametric now.
This subsumed the tests that existed only to pin a specific line ending or the presence of a trailing break, including the one you marked One decision I'd like you to check, as it's a behavior change you didn't ask for. While adding the trim argument I'd made an empty prefix stop being a short-circuit, so On the tests being reviewed by human eyes: fair, and they have been now. Two things came out of it beyond the empty-prefix decision. The 4th assertion in
|
This adds four string built-ins that make it easier to format multi-line text, which is useful when generating source code, configuration files, documentation comments, and similar structured output.
?indent(prefix)— prependsprefixto each (non-empty) line.?dedent(prefix)— removesprefixfrom the start of each line that has it (the inverse of?indent).?wrap(width, firstPrefix[, restPrefix])— word-wraps the string to the given column width, with configurable per-line prefixes (handy for wrapped comment blocks).?pad_lines(width[, fillChar])— pads each line on the right to the given column. Unlike?right_pad, which pads the string as a whole, this operates per line, which is useful for aligning multi-line text.All four operate on the string they're applied to, have no side effects, and require no new configuration or language mode. Line breaks (LF, CR, CRLF) are recognized and preserved.
Backward compatibility
Purely additive — these are new built-in names, so existing templates are unaffected.
Testing & docs
IndentAndWrapBuiltInTest.@since 2.3.35)../gradlew checkand./gradlew manualOfflineboth pass.Background
These were developed for a code-generation workflow (generating embedded C in ChibiOS, via FMPP), but they're generally useful for any multi-line output.