Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions lexilla/np3_patches/003_LexMarkdown_AtTermStart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Patch 003: LexMarkdown — AtTermStart accepts opening punctuation

**File:** `lexilla/lexers/LexMarkdown.cxx`
**Status:** NP3 local fix (candidate for upstream submission)
**First applied:** 2026-05-21

## Problem

The stock Lexilla Markdown lexer fails to recognise inline spans that
open immediately after common opening punctuation. The user-reported
case is inline code:

```
(`Hello`) <- not highlighted
( `Hello`) <- highlighted (extra space)
```

The same defect rejects:

```
[`x`] {`x`} <`x`> "`x`" '`x`'
(**x**) [**x**] (~~x~~) (*x*) (_x_)
```

VS Code and other CommonMark-compliant renderers accept all of these.
Per CommonMark:

- **Code spans** have no left-flank restriction whatsoever.
- **Emphasis / strong** opening uses left-flanking-delimiter-run rules;
a delimiter preceded by Unicode punctuation and followed by a
non-whitespace non-punctuation char is a valid left flank.

## Root cause

`AtTermStart` (helper used by `IsCompleteStyleRegion` and the multi-
backtick code-span entry point) only returns `true` when `chPrev` is
whitespace or start-of-file:

```cpp
bool AtTermStart(const StyleContext &sc) noexcept {
return sc.currentPos == 0 || sc.chPrev == 0 || isspacechar(sc.chPrev);
}
```

With `chPrev = '('`, the guard rejects the opening backtick and the
span never enters `SCE_MARKDOWN_CODE`/`SCE_MARKDOWN_CODE2`. Same for
`SCE_MARKDOWN_STRONG{1,2}`, `SCE_MARKDOWN_EM{1,2}`, and
`SCE_MARKDOWN_STRIKEOUT`.

## Fix

Extend `AtTermStart` to additionally accept `(`, `[`, `{`, `<`, `"`,
`'` as valid left-edge characters. This covers all bracketed and
quoted forms users typically write, without loosening behaviour for
mid-word delimiters (`foo*bar*` remains unchanged — `chPrev = 'o'` is
still rejected).

```cpp
bool AtTermStart(const StyleContext &sc) noexcept {
if (sc.currentPos == 0 || sc.chPrev == 0 || isspacechar(sc.chPrev))
return true;
switch (sc.chPrev) {
case '(': case '[': case '{': case '<':
case '"': case '\'':
return true;
default:
return false;
}
}
```

## Scope

Affects all inline tokens gated by `IsCompleteStyleRegion`:

- `` ` `` (single-backtick code)
- `` `` `` `` (multi-backtick code, via direct `AtTermStart(sc)` call)
- `**` / `__` (strong)
- `*` / `_` (emphasis)
- `~~` (strikeout)

Block-level constructs (headers, lists, code blocks, blockquote,
hrule, links) are untouched.

## Visual verification

Open `test/test_files/StyleLexers/styleLexMARKDOWN/README.md` in
Notepad3 after build. The section appended for this patch demonstrates
each adjacency variant — inline code/strong/em/strikeout following
every opener should colour correctly.

## Upstream

This is an upstream Lexilla defect. The fix is small and self-
contained; a PR against ScintillaOrg/lexilla would be welcome. Until
then, keep this patch.

## Upgrade procedure

After a Lexilla upgrade:

1. Diff `lexilla/lexers/LexMarkdown.cxx` against the upstream copy.
2. Reapply this patch (`003_LexMarkdown_AtTermStart.patch`) if the
upstream still ships the whitespace-only `AtTermStart`.
3. If upstream has integrated the fix, retire this patch and remove
its row from `README.md`.
27 changes: 27 additions & 0 deletions lexilla/np3_patches/003_LexMarkdown_AtTermStart.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
diff --git a/lexilla/lexers/LexMarkdown.cxx b/lexilla/lexers/LexMarkdown.cxx
index 667b3b534..f58dab187 100644
--- a/lexilla/lexers/LexMarkdown.cxx
+++ b/lexilla/lexers/LexMarkdown.cxx
@@ -119,7 +119,21 @@ bool HasPrevLineContent(StyleContext &sc) {
}

bool AtTermStart(const StyleContext &sc) noexcept {
- return sc.currentPos == 0 || sc.chPrev == 0 || isspacechar(sc.chPrev);
+ if (sc.currentPos == 0 || sc.chPrev == 0 || isspacechar(sc.chPrev))
+ return true;
+ // NP3 patch: also accept common opening punctuation so inline spans
+ // (code, strong, emphasis, strikeout) can open directly after them,
+ // e.g. (`x`), [`x`], {`x`}, <`x`>, "`x`", '`x`'. CommonMark and VS
+ // Code accept these — code spans have no left-flank restriction at
+ // all, and emphasis after opening punctuation is a valid
+ // left-flanking delimiter run.
+ switch (sc.chPrev) {
+ case '(': case '[': case '{': case '<':
+ case '"': case '\'':
+ return true;
+ default:
+ return false;
+ }
}

bool IsCompleteStyleRegion(StyleContext &sc, const char *token) {
79 changes: 75 additions & 4 deletions readme/tinyexprcpp/TinyExprPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,9 @@ SQRT(3^2 + 4^2)=? → 5
### Unit Conversions

```
72 * 0.0254=? → 1.8288 (72 inches to meters)
100 / 2.54=? → 39.3701 (100 cm to inches)
(98.6 - 32) * 5/9=? → 37 (Fahrenheit to Celsius)
72 * 0.0254=? → 1.8288 (72 inches to meters)
100 / 2.54=? → 39.3700787401575 (100 cm to inches)
(98.6 - 32) * 5/9=? → 37 (Fahrenheit to Celsius)
```

### Programming Helpers
Expand Down Expand Up @@ -361,7 +361,7 @@ TinyExpr++ in Notepad3 automatically adapts to the **decimal separator** of the
| **Function argument separator** | `,` (comma) | `;` (semicolon) |
| **Number example** | `3.14` | `3,14` |
| **Function call** | `SUM(1.5, 2)` | `SUM(1,5; 2)` |
| **Inline evaluation** | `1/3=?` → `0.33333333` | `1/3=?` → `0,33333333` |
| **Inline evaluation** | `1/3=?` → `0.333333333333333` | `1/3=?` → `0,333333333333333` |

### Examples by Locale

Expand All @@ -383,6 +383,77 @@ IF(2,5 > 1; 10; 20)=? → 10

---

## C API: Boolean-Aware Evaluation (developer reference)

The C wrapper around TinyExpr++ exposes an evaluator that renders results
of boolean-looking expressions as the words `true` / `false`, so callers
don't have to invent their own classification scheme:

```c
#include "tinyexpr_cif.h"

const char *te_interp_str(const char *expression, te_int_t *error);
```

The function evaluates the expression once and returns a thread-local
internal buffer. The returned pointer remains valid until the next call
from the same thread; `*error` follows the same convention as `te_interp()`
(`0` on success, 1-based parse-error position on failure).

| Returned string | When |
|-----------------|------|
| `"true"` / `"false"` | Expression is lexically logical **and** evaluates to exactly `1.0` / `0.0`. |
| `"nan"`, `"inf"`, `"-inf"` | Result is non-finite (e.g., `0/0`, `LN(0)`, comparison involving `NaN`). |
| Integer-like (`%.21g`) | Finite value whose fractional part is below `1e-15` and whose magnitude is below `1e21`. |
| Decimal (`%.15g`) | All other finite results. |

The numeric formatting mirrors Notepad3's `TinyExprToStringA` exactly, so values returned by `te_interp_str()` match what the status bar / `=?` inline-replacement would render. Hex / binary output modes are UI-level concerns and remain in `TinyExprToStringA` proper.

### When is an expression classified as logical?

Classification requires **both** of the following to hold:

1. **Lexical hit** at parenthesis depth 0 (one fully-enclosing outer
pair is stripped first, so `(1==1)` is treated like `1==1`):
- a relational / equality / logical operator: `==`, `=`, `!=`, `<>`,
`<=`, `>=`, `<`, `>`, `&&`, `||`, leading `!`
- the bare keywords `true` / `false`
- an outermost call to `AND`, `OR`, `NOT`, `ISERR`, `ISERROR`,
`ISNA`, `ISNAN`, `ISEVEN`, or `ISODD` (case-insensitive)
2. **Value hit**: the finite evaluation result is exactly `0.0` or `1.0`.

Bit-shift (`<<`, `>>`) and bit-rotate (`<<<`, `>>>`) are consumed by the
scanner without triggering classification. Block comments (`/* … */`) and
line comments (`// …`) are skipped, mirroring the parser.

`IF` / `IFS` are intentionally **not** in the predicate list — they return
arbitrary user-supplied values, so `IF(a>b, 5, 10)` is numeric, not
boolean.

### Examples

| Expression | Returns | Reason |
|------------|---------|--------|
| `1+1=2+2` | `"false"` | Parses as `(1+1) == (2+2)`; lone `=` is equality. |
| `1+1=2` | `"true"` | Same path; `2 == 2` is true. |
| `(1==1)` | `"true"` | Outer parens stripped before the lexical scan. |
| `ISEVEN(4)` | `"true"` | Top-level call to a predicate function. |
| `1 && 0` | `"false"` | Logical AND at depth 0. |
| `!0` | `"true"` | Unary logical-NOT. |
| `IF(1>2, 100, 200)` | `"200"` | `IF` is not a predicate; `>` is inside parens. |
| `1 + (1==1)` | `"2"` | `==` is inside parens, and the result isn't 0/1. |
| `(1==1) * (2==2)` | `"1"` | No comparison at depth 0; result is arithmetic. |
| `0/0 == 1` | `"nan"` | Non-finite result bypasses classification. |
| `2*PI` | `"6.28318530717959"` | No logical operator; `%.15g` format. |

> **Why both checks?** A purely value-based test would mis-label `1+0` as
> a boolean. A purely lexical test would mis-label `IF(a>b, 5, 10)`
> (which evaluates to `5` or `10`, not `0`/`1`). The intersection is much
> closer to user intent. `te_interp()` and `te_compile()` remain
> unchanged for callers that prefer to handle classification themselves.

---

## Notes

- All function names are **case-insensitive**.
Expand Down
50 changes: 36 additions & 14 deletions src/Notepad3.c
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,9 @@ static bool s_bUndoRedoScroll = false;
// Auto-scroll state moved to Notepad3Util.c

// for tiny expression calculation
static double s_dExpression = 0.0;
static te_int_t s_iExprError = -1;
static double s_dExpression = 0.0;
static te_int_t s_iExprError = -1;
static bool s_bExprIsLogical = false;

// TinyExpr++ output mode (process-local, cycled by double-click on STATUS_TINYEXPR)
typedef enum TE_OUT_MODE_T {
Expand Down Expand Up @@ -614,9 +615,9 @@ static inline void ResetFileObservationData(const bool bResetEvt) {
}
// ----------------------------------------------------------------------------

#define TE_ZERO (1.0E-8)
#define TE_FMTA "%.8G"
#define TE_FMTW L"%.8G"
#define TE_ZERO (1.0E-15)
#define TE_FMTA "%.15g"
#define TE_FMTW L"%.15g"

// Bounds for safe double -> signed-integer cast. (double)INT_MAX/INT64_MAX may
// round UP to the next power of two, so use literals strictly below 2^31 / 2^63.
Expand Down Expand Up @@ -694,8 +695,14 @@ static void _FormatBinW(LPWSTR pszDest, size_t cchDest, unsigned __int64 u, int
pszDest[pos] = L'\0';
}

void TinyExprToStringA(LPSTR pszDest, size_t cchDest, const double dExprEval)
void TinyExprToStringA(LPSTR pszDest, size_t cchDest, const double dExprEval, const bool bIsLogical)
{
// Logical expression with a finite 0/1 result -> render as true/false,
// overriding hex/binary output modes (booleans are not a numeric value).
if (bIsLogical && isfinite(dExprEval) && (dExprEval == 0.0 || dExprEval == 1.0)) {
StringCchCopyA(pszDest, cchDest, (dExprEval == 1.0) ? "true" : "false");
return;
}
if ((s_iTinyExprOutMode != TE_OUT_DEC) && isfinite(dExprEval) && (fabs(dExprEval) < _TinyExprIntBound())) {
int const width = _TinyExprBitWidth();
__int64 const i64 = (__int64)llround(dExprEval);
Expand All @@ -717,16 +724,20 @@ void TinyExprToStringA(LPSTR pszDest, size_t cchDest, const double dExprEval)
double intpart = 0.0;
double const fracpart = modf(dExprEval, &intpart);
if ((fabs(fracpart) < TE_ZERO) && (fabs(intpart) < 1.0E+21)) {
StringCchPrintfA(pszDest, cchDest, "%.21G", intpart); // integer full number display
StringCchPrintfA(pszDest, cchDest, "%.21g", intpart); // integer full number display
}
else {
StringCchPrintfA(pszDest, cchDest, TE_FMTA, dExprEval);
}
}
// ----------------------------------------------------------------------------

void TinyExprToString(LPWSTR pszDest, size_t cchDest, const double dExprEval)
void TinyExprToString(LPWSTR pszDest, size_t cchDest, const double dExprEval, const bool bIsLogical)
{
if (bIsLogical && isfinite(dExprEval) && (dExprEval == 0.0 || dExprEval == 1.0)) {
StringCchCopy(pszDest, cchDest, (dExprEval == 1.0) ? L"true" : L"false");
return;
}
if ((s_iTinyExprOutMode != TE_OUT_DEC) && isfinite(dExprEval) && (fabs(dExprEval) < _TinyExprIntBound())) {
int const width = _TinyExprBitWidth();
__int64 const i64 = (__int64)llround(dExprEval);
Expand All @@ -748,7 +759,7 @@ void TinyExprToString(LPWSTR pszDest, size_t cchDest, const double dExprEval)
double intpart = 0.0;
double const fracpart = modf(dExprEval, &intpart);
if ((fabs(fracpart) < TE_ZERO) && (fabs(intpart) < 1.0E+21)) {
StringCchPrintf(pszDest, cchDest, L"%.21G", intpart); // integer full number display
StringCchPrintf(pszDest, cchDest, L"%.21g", intpart); // integer full number display
}
else {
StringCchPrintf(pszDest, cchDest, TE_FMTW, dExprEval);
Expand All @@ -767,7 +778,7 @@ static VOID CALLBACK TinyExprCopyTimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEven

char chExpr[80] = { '\0' };
if (s_iExprError == 0) {
TinyExprToStringA(chExpr, COUNTOF(chExpr), s_dExpression);
TinyExprToStringA(chExpr, COUNTOF(chExpr), s_dExpression, s_bExprIsLogical);
} else if (s_iExprError > 0) {
StringCchPrintfA(chExpr, COUNTOF(chExpr), "%s^[" TE_INT_FMT "]",
s_pszTinyExprModePrefixA[s_iTinyExprOutMode], s_iExprError);
Expand Down Expand Up @@ -3001,16 +3012,22 @@ static bool _EvalTinyExpr(bool qmark)

double dExprEval = 0.0;
te_int_t exprErr = 1;
bool bExprIsLogical = false;
while (*p && exprErr) {
dExprEval = te_interp(p, &exprErr);
if (!exprErr) {
// `p` hasn't moved yet (the advance happens in the inner
// while below); safe to classify the just-evaluated text.
bExprIsLogical = (te_is_logical_expr(p) != 0);
}
// proceed to next possible expression
while (*++p && exprErr && !(te_is_num(p) || te_is_op(p))) {}
}
FreeMem(lineBuf);

if (!exprErr) {
char chExpr[80] = { '\0' };
TinyExprToStringA(chExpr, COUNTOF(chExpr), dExprEval);
TinyExprToStringA(chExpr, COUNTOF(chExpr), dExprEval, bExprIsLogical);
SciCall_ReplaceSel("");
SciCall_SetSel(posBegin, posSelStart);
SciCall_ReplaceSel(chExpr);
Expand Down Expand Up @@ -11526,7 +11543,8 @@ static void _UpdateStatusbarDelayed(bool bForceRedraw)
if (g_iStatusbarVisible[STATUS_TINYEXPR]) {
static WCHAR tchExpression[80] = { L'\0' }; // fits "0b" + 64 bits + NUL with headroom
static te_int_t s_iExErr = -3;
s_dExpression = 0.0;
s_dExpression = 0.0;
s_bExprIsLogical = false;
StringCchPrintf(tchExpression, COUNTOF(tchExpression), L"%s--",
s_pszTinyExprModePrefixW[s_iTinyExprOutMode]);

Expand All @@ -11546,11 +11564,15 @@ static void _UpdateStatusbarDelayed(bool bForceRedraw)
WideCharToMultiByte(1252, (WC_COMPOSITECHECK | WC_DISCARDNS), wchSelBuf, -1, chSeBuf, LARGE_BUFFER, &defchar, NULL);
StrDelChrA(chSeBuf, chr_currency);

s_dExpression = te_interp(chSeBuf, &s_iExprError);
s_dExpression = te_interp(chSeBuf, &s_iExprError);
s_bExprIsLogical = (s_iExprError == 0) && (te_is_logical_expr(chSeBuf) != 0);
} else {
s_iExprError = -1;
}
} else if (Sci_IsMultiOrRectangleSelection() && !bIsSelectionEmpty) {
// Multi-/rect-selection concatenates fragments into a synthesized
// expression; the user-typed source isn't preserved, so don't
// try to classify it as logical.
s_dExpression = _InterpMultiSelectionTinyExpr(&s_iExprError);
} else {
s_iExprError = -2;
Expand All @@ -11560,7 +11582,7 @@ static void _UpdateStatusbarDelayed(bool bForceRedraw)
}

if (!s_iExprError) {
TinyExprToString(tchExpression, COUNTOF(tchExpression), s_dExpression);
TinyExprToString(tchExpression, COUNTOF(tchExpression), s_dExpression, s_bExprIsLogical);
} else if (s_iExprError > 0) {
StringCchPrintf(tchExpression, COUNTOF(tchExpression), L"%s^[" _W(TE_INT_FMT) L"]",
s_pszTinyExprModePrefixW[s_iTinyExprOutMode], s_iExprError);
Expand Down
Loading
Loading