From e327dd1833873e2c9650d94d25ce9314fad1da92 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 14:52:54 +0200 Subject: [PATCH 01/33] BUGS: File several bugs. --- BUGS-TO-REPORT.md | 1267 +---------------- .../repro.json | 7 - .../repro.json | 7 - bugs/filed/advise-nothing-crashes/REPORT.md | 44 + .../advise-nothing-crashes.twinproj | Bin .../advise-nothing-crashes/repro.json | 3 +- .../advise-nothing-crashes/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../advise-nothing-crashes/vb6/Module1.bas | 0 .../advise-nothing-crashes/vb6/Probe.vbp | 0 .../advise-nothing-crashes/vb6/Source.cls | 0 bugs/filed/advise-unadvise-hresults/REPORT.md | 44 + .../advise-unadvise-hresults.twinproj | Bin .../advise-unadvise-hresults/repro.json | 3 +- .../advise-unadvise-hresults/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../advise-unadvise-hresults/vb6/Module1.bas | 0 .../advise-unadvise-hresults/vb6/NotASink.cls | 0 .../advise-unadvise-hresults/vb6/Probe.vbp | 0 .../advise-unadvise-hresults/vb6/Source.cls | 0 .../REPORT.md | 41 + ...y-const-after-procedure-not-found.twinproj | Bin .../repro.json | 10 + .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../REPORT.md | 36 + ...rray-const-element-overflow-crash.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../REPORT.md | 37 + .../array-const-in-array-const-crash.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../REPORT.md | 41 + ...ay-const-paramarray-codegen-error.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../REPORT.md | 33 + ...ss-public-array-const-unreachable.twinproj | Bin .../repro.json | 11 + .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../src/Sources/WithArray.twin | 0 .../src/Sources/WithScalar.twin | 0 .../forecolor-invalid-value-stored/REPORT.md | 72 + .../forecolor-invalid-value-stored.twinproj | Bin .../forecolor-invalid-value-stored/repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Form1.tbform | 0 .../src/Sources/Form1.twin | 0 .../src/Sources/MyMDIForm.tbform | 0 .../src/Sources/MyMDIForm.twin | 0 .../src/Sources/Startup.twin | 0 .../src/Sources/UC1.tbcontrol | 0 .../src/Sources/UC1.twin | 0 .../vb6/Form1.frm | 0 .../vb6/MDIForm1.frm | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../vb6/UC1.ctl | 0 bugs/filed/gettypeinfo-badindex/REPORT.md | 40 + .../gettypeinfo-badindex.twinproj | Bin .../gettypeinfo-badindex/repro.json | 3 +- .../gettypeinfo-badindex/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../gettypeinfo-badindex/vb6/Module1.bas | 0 .../gettypeinfo-badindex/vb6/Probe.vbp | 0 .../gettypeinfo-badindex/vb6/Widget.cls | 0 .../filed/import-no-settings-silent/REPORT.md | 32 + .../import-no-settings-silent.twinproj | Bin .../import-no-settings-silent/repro.json | 19 + .../import-no-settings-silent/src/Settings | 0 .../src/Sources/Startup.twin | 0 bugs/filed/latebound-call-retried/REPORT.md | 42 + .../latebound-call-retried.twinproj | Bin .../latebound-call-retried/repro.json | 3 +- .../latebound-call-retried/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../latebound-call-retried/vb6/Module1.bas | 0 .../latebound-call-retried/vb6/Probe.vbp | 0 .../latebound-call-retried/vb6/Widget.cls | 0 .../latebound-unknown-member-error/REPORT.md | 41 + .../latebound-unknown-member-error.twinproj | Bin .../latebound-unknown-member-error/repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../vb6/Widget.cls | 0 .../REPORT.md | 35 + .../llvm-conversion-rounds-half-away.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../llvm-double-overflow-no-error/REPORT.md | 55 + .../llvm-double-overflow-no-error.twinproj | Bin .../llvm-double-overflow-no-error/repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../llvm-shift-count-truncated/REPORT.md | 45 + .../llvm-shift-count-truncated.twinproj | Bin .../llvm-shift-count-truncated/repro.json | 3 +- .../llvm-shift-count-truncated/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../lset-variant-destination-types/REPORT.md | 30 + .../lset-variant-destination-types.twinproj | Bin .../lset-variant-destination-types/repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../REPORT.md | 37 + ...-intrinsics-ignore-argument-names.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../filed/module-named-like-project/REPORT.md | 44 + .../module-named-like-project.twinproj | Bin .../module-named-like-project/repro.json | 3 +- .../module-named-like-project/src/Settings | 0 .../src/Sources/ModuleNamedLikeProject.twin | 0 .../module-named-like-project/vb6/Module1.bas | 0 .../module-named-like-project/vb6/Probe.vbp | 0 bugs/filed/new-on-interface/REPORT.md | 35 + .../new-on-interface.twinproj | Bin bugs/{ => filed}/new-on-interface/repro.json | 3 +- .../{ => filed}/new-on-interface/src/Settings | 0 .../new-on-interface/src/Sources/Startup.twin | 0 bugs/filed/on-property-ignored/REPORT.md | 42 + .../on-property-ignored.twinproj | Bin .../on-property-ignored/repro.json | 3 +- .../on-property-ignored/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../raiseevent-plain-html-typeerror/REPORT.md | 39 + .../raiseevent-plain-html-typeerror.twinproj | Bin .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 bugs/filed/single-overflow-no-error/REPORT.md | 53 + .../single-overflow-no-error/repro.json | 3 +- .../single-overflow-no-error.twinproj | Bin .../single-overflow-no-error/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../single-overflow-no-error/vb6/Module1.bas | 0 .../single-overflow-no-error/vb6/Probe.vbp | 0 bugs/filed/static-ctor-args/REPORT.md | 46 + bugs/filed/static-ctor-args/repro.json | 9 + .../{ => filed}/static-ctor-args/src/Settings | 0 .../static-ctor-args/src/Sources/Probe.twin | 0 .../src/Sources/ProbeDog.twin | 0 .../static-ctor-args/src/Sources/Startup.twin | 0 .../static-ctor-args.twinproj | Bin bugs/filed/sub-named-like-module/REPORT.md | 47 + .../sub-named-like-module/repro.json | 3 +- .../sub-named-like-module/src/Settings | 0 .../src/Sources/Probe.twin | 0 .../src/Sources/Startup.twin | 0 .../sub-named-like-module.twinproj | Bin .../sub-named-like-module/vb6/Module1.bas | 0 .../sub-named-like-module/vb6/Probe.vbp | 0 .../system-colour-extra-bytes-black/REPORT.md | 40 + .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Form1.tbform | 0 .../src/Sources/Form1.twin | 0 .../src/Sources/Startup.twin | 0 .../system-colour-extra-bytes-black.twinproj | Bin .../vb6/Form1.frm | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../textbox-text-ignores-maxlength/REPORT.md | 34 + .../textbox-text-ignores-maxlength/repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Form1.tbform | 0 .../src/Sources/Form1.twin | 0 .../src/Sources/Startup.twin | 0 .../textbox-text-ignores-maxlength.twinproj | Bin .../vb6/Form1.frm | 0 .../vb6/Module1.bas | 0 .../vb6/Probe.vbp | 0 .../REPORT.md | 46 + .../repro.json | 3 +- .../src/Settings | 0 .../src/Sources/Startup.twin | 0 .../two-calling-conventions-accepted.twinproj | Bin bugs/filed/udt-watch-codegen-error/REPORT.md | 45 + .../udt-watch-codegen-error/repro.json | 3 +- .../udt-watch-codegen-error/src/Settings | 0 .../src/Sources/Startup.twin | 0 .../udt-watch-codegen-error.twinproj | Bin bugs/import-no-settings-silent/repro.json | 9 - bugs/static-ctor-args/repro.json | 6 - 199 files changed, 1301 insertions(+), 1292 deletions(-) delete mode 100644 bugs/array-const-after-procedure-not-found/repro.json delete mode 100644 bugs/class-public-array-const-unreachable/repro.json create mode 100644 bugs/filed/advise-nothing-crashes/REPORT.md rename bugs/{ => filed}/advise-nothing-crashes/advise-nothing-crashes.twinproj (100%) rename bugs/{ => filed}/advise-nothing-crashes/repro.json (86%) rename bugs/{ => filed}/advise-nothing-crashes/src/Settings (100%) rename bugs/{ => filed}/advise-nothing-crashes/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/advise-nothing-crashes/vb6/Module1.bas (100%) rename bugs/{ => filed}/advise-nothing-crashes/vb6/Probe.vbp (100%) rename bugs/{ => filed}/advise-nothing-crashes/vb6/Source.cls (100%) create mode 100644 bugs/filed/advise-unadvise-hresults/REPORT.md rename bugs/{ => filed}/advise-unadvise-hresults/advise-unadvise-hresults.twinproj (100%) rename bugs/{ => filed}/advise-unadvise-hresults/repro.json (90%) rename bugs/{ => filed}/advise-unadvise-hresults/src/Settings (100%) rename bugs/{ => filed}/advise-unadvise-hresults/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/advise-unadvise-hresults/vb6/Module1.bas (100%) rename bugs/{ => filed}/advise-unadvise-hresults/vb6/NotASink.cls (100%) rename bugs/{ => filed}/advise-unadvise-hresults/vb6/Probe.vbp (100%) rename bugs/{ => filed}/advise-unadvise-hresults/vb6/Source.cls (100%) create mode 100644 bugs/filed/array-const-after-procedure-not-found/REPORT.md rename bugs/{ => filed}/array-const-after-procedure-not-found/array-const-after-procedure-not-found.twinproj (100%) create mode 100644 bugs/filed/array-const-after-procedure-not-found/repro.json rename bugs/{ => filed}/array-const-after-procedure-not-found/src/Settings (100%) rename bugs/{ => filed}/array-const-after-procedure-not-found/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/array-const-element-overflow-crash/REPORT.md rename bugs/{ => filed}/array-const-element-overflow-crash/array-const-element-overflow-crash.twinproj (100%) rename bugs/{array-const-in-array-const-crash => filed/array-const-element-overflow-crash}/repro.json (71%) rename bugs/{ => filed}/array-const-element-overflow-crash/src/Settings (100%) rename bugs/{ => filed}/array-const-element-overflow-crash/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/array-const-in-array-const-crash/REPORT.md rename bugs/{ => filed}/array-const-in-array-const-crash/array-const-in-array-const-crash.twinproj (100%) rename bugs/{array-const-element-overflow-crash => filed/array-const-in-array-const-crash}/repro.json (71%) rename bugs/{ => filed}/array-const-in-array-const-crash/src/Settings (100%) rename bugs/{ => filed}/array-const-in-array-const-crash/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/array-const-paramarray-codegen-error/REPORT.md rename bugs/{ => filed}/array-const-paramarray-codegen-error/array-const-paramarray-codegen-error.twinproj (100%) rename bugs/{ => filed}/array-const-paramarray-codegen-error/repro.json (78%) rename bugs/{ => filed}/array-const-paramarray-codegen-error/src/Settings (100%) rename bugs/{ => filed}/array-const-paramarray-codegen-error/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/class-public-array-const-unreachable/REPORT.md rename bugs/{ => filed}/class-public-array-const-unreachable/class-public-array-const-unreachable.twinproj (100%) create mode 100644 bugs/filed/class-public-array-const-unreachable/repro.json rename bugs/{ => filed}/class-public-array-const-unreachable/src/Settings (100%) rename bugs/{ => filed}/class-public-array-const-unreachable/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/class-public-array-const-unreachable/src/Sources/WithArray.twin (100%) rename bugs/{ => filed}/class-public-array-const-unreachable/src/Sources/WithScalar.twin (100%) create mode 100644 bugs/filed/forecolor-invalid-value-stored/REPORT.md rename bugs/{ => filed}/forecolor-invalid-value-stored/forecolor-invalid-value-stored.twinproj (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/repro.json (98%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Settings (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/Form1.tbform (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/Form1.twin (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/MyMDIForm.tbform (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/MyMDIForm.twin (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/UC1.tbcontrol (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/src/Sources/UC1.twin (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/vb6/Form1.frm (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/vb6/MDIForm1.frm (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/vb6/Module1.bas (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/vb6/Probe.vbp (100%) rename bugs/{ => filed}/forecolor-invalid-value-stored/vb6/UC1.ctl (100%) create mode 100644 bugs/filed/gettypeinfo-badindex/REPORT.md rename bugs/{ => filed}/gettypeinfo-badindex/gettypeinfo-badindex.twinproj (100%) rename bugs/{ => filed}/gettypeinfo-badindex/repro.json (89%) rename bugs/{ => filed}/gettypeinfo-badindex/src/Settings (100%) rename bugs/{ => filed}/gettypeinfo-badindex/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/gettypeinfo-badindex/vb6/Module1.bas (100%) rename bugs/{ => filed}/gettypeinfo-badindex/vb6/Probe.vbp (100%) rename bugs/{ => filed}/gettypeinfo-badindex/vb6/Widget.cls (100%) create mode 100644 bugs/filed/import-no-settings-silent/REPORT.md rename bugs/{ => filed}/import-no-settings-silent/import-no-settings-silent.twinproj (100%) create mode 100644 bugs/filed/import-no-settings-silent/repro.json rename bugs/{ => filed}/import-no-settings-silent/src/Settings (100%) rename bugs/{ => filed}/import-no-settings-silent/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/latebound-call-retried/REPORT.md rename bugs/{ => filed}/latebound-call-retried/latebound-call-retried.twinproj (100%) rename bugs/{ => filed}/latebound-call-retried/repro.json (90%) rename bugs/{ => filed}/latebound-call-retried/src/Settings (100%) rename bugs/{ => filed}/latebound-call-retried/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/latebound-call-retried/vb6/Module1.bas (100%) rename bugs/{ => filed}/latebound-call-retried/vb6/Probe.vbp (100%) rename bugs/{ => filed}/latebound-call-retried/vb6/Widget.cls (100%) create mode 100644 bugs/filed/latebound-unknown-member-error/REPORT.md rename bugs/{ => filed}/latebound-unknown-member-error/latebound-unknown-member-error.twinproj (100%) rename bugs/{ => filed}/latebound-unknown-member-error/repro.json (92%) rename bugs/{ => filed}/latebound-unknown-member-error/src/Settings (100%) rename bugs/{ => filed}/latebound-unknown-member-error/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/latebound-unknown-member-error/vb6/Module1.bas (100%) rename bugs/{ => filed}/latebound-unknown-member-error/vb6/Probe.vbp (100%) rename bugs/{ => filed}/latebound-unknown-member-error/vb6/Widget.cls (100%) create mode 100644 bugs/filed/llvm-conversion-rounds-half-away/REPORT.md rename bugs/{ => filed}/llvm-conversion-rounds-half-away/llvm-conversion-rounds-half-away.twinproj (100%) rename bugs/{ => filed}/llvm-conversion-rounds-half-away/repro.json (94%) rename bugs/{ => filed}/llvm-conversion-rounds-half-away/src/Settings (100%) rename bugs/{ => filed}/llvm-conversion-rounds-half-away/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/llvm-conversion-rounds-half-away/vb6/Module1.bas (100%) rename bugs/{ => filed}/llvm-conversion-rounds-half-away/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/llvm-double-overflow-no-error/REPORT.md rename bugs/{ => filed}/llvm-double-overflow-no-error/llvm-double-overflow-no-error.twinproj (100%) rename bugs/{ => filed}/llvm-double-overflow-no-error/repro.json (94%) rename bugs/{ => filed}/llvm-double-overflow-no-error/src/Settings (100%) rename bugs/{ => filed}/llvm-double-overflow-no-error/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/llvm-double-overflow-no-error/vb6/Module1.bas (100%) rename bugs/{ => filed}/llvm-double-overflow-no-error/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/llvm-shift-count-truncated/REPORT.md rename bugs/{ => filed}/llvm-shift-count-truncated/llvm-shift-count-truncated.twinproj (100%) rename bugs/{ => filed}/llvm-shift-count-truncated/repro.json (93%) rename bugs/{ => filed}/llvm-shift-count-truncated/src/Settings (100%) rename bugs/{ => filed}/llvm-shift-count-truncated/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/lset-variant-destination-types/REPORT.md rename bugs/{ => filed}/lset-variant-destination-types/lset-variant-destination-types.twinproj (100%) rename bugs/{ => filed}/lset-variant-destination-types/repro.json (91%) rename bugs/{ => filed}/lset-variant-destination-types/src/Settings (100%) rename bugs/{ => filed}/lset-variant-destination-types/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/lset-variant-destination-types/vb6/Module1.bas (100%) rename bugs/{ => filed}/lset-variant-destination-types/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/math-intrinsics-ignore-argument-names/REPORT.md rename bugs/{ => filed}/math-intrinsics-ignore-argument-names/math-intrinsics-ignore-argument-names.twinproj (100%) rename bugs/{ => filed}/math-intrinsics-ignore-argument-names/repro.json (94%) rename bugs/{ => filed}/math-intrinsics-ignore-argument-names/src/Settings (100%) rename bugs/{ => filed}/math-intrinsics-ignore-argument-names/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/module-named-like-project/REPORT.md rename bugs/{ => filed}/module-named-like-project/module-named-like-project.twinproj (100%) rename bugs/{ => filed}/module-named-like-project/repro.json (91%) rename bugs/{ => filed}/module-named-like-project/src/Settings (100%) rename bugs/{ => filed}/module-named-like-project/src/Sources/ModuleNamedLikeProject.twin (100%) rename bugs/{ => filed}/module-named-like-project/vb6/Module1.bas (100%) rename bugs/{ => filed}/module-named-like-project/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/new-on-interface/REPORT.md rename bugs/{ => filed}/new-on-interface/new-on-interface.twinproj (100%) rename bugs/{ => filed}/new-on-interface/repro.json (93%) rename bugs/{ => filed}/new-on-interface/src/Settings (100%) rename bugs/{ => filed}/new-on-interface/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/on-property-ignored/REPORT.md rename bugs/{ => filed}/on-property-ignored/on-property-ignored.twinproj (100%) rename bugs/{ => filed}/on-property-ignored/repro.json (92%) rename bugs/{ => filed}/on-property-ignored/src/Settings (100%) rename bugs/{ => filed}/on-property-ignored/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/raiseevent-plain-html-typeerror/REPORT.md rename bugs/{ => filed}/raiseevent-plain-html-typeerror/raiseevent-plain-html-typeerror.twinproj (100%) rename bugs/{ => filed}/raiseevent-plain-html-typeerror/repro.json (97%) rename bugs/{ => filed}/raiseevent-plain-html-typeerror/src/Settings (100%) rename bugs/{ => filed}/raiseevent-plain-html-typeerror/src/Sources/Startup.twin (100%) create mode 100644 bugs/filed/single-overflow-no-error/REPORT.md rename bugs/{ => filed}/single-overflow-no-error/repro.json (93%) rename bugs/{ => filed}/single-overflow-no-error/single-overflow-no-error.twinproj (100%) rename bugs/{ => filed}/single-overflow-no-error/src/Settings (100%) rename bugs/{ => filed}/single-overflow-no-error/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/single-overflow-no-error/vb6/Module1.bas (100%) rename bugs/{ => filed}/single-overflow-no-error/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/static-ctor-args/REPORT.md create mode 100644 bugs/filed/static-ctor-args/repro.json rename bugs/{ => filed}/static-ctor-args/src/Settings (100%) rename bugs/{ => filed}/static-ctor-args/src/Sources/Probe.twin (100%) rename bugs/{ => filed}/static-ctor-args/src/Sources/ProbeDog.twin (100%) rename bugs/{ => filed}/static-ctor-args/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/static-ctor-args/static-ctor-args.twinproj (100%) create mode 100644 bugs/filed/sub-named-like-module/REPORT.md rename bugs/{ => filed}/sub-named-like-module/repro.json (91%) rename bugs/{ => filed}/sub-named-like-module/src/Settings (100%) rename bugs/{ => filed}/sub-named-like-module/src/Sources/Probe.twin (100%) rename bugs/{ => filed}/sub-named-like-module/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/sub-named-like-module/sub-named-like-module.twinproj (100%) rename bugs/{ => filed}/sub-named-like-module/vb6/Module1.bas (100%) rename bugs/{ => filed}/sub-named-like-module/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/system-colour-extra-bytes-black/REPORT.md rename bugs/{ => filed}/system-colour-extra-bytes-black/repro.json (88%) rename bugs/{ => filed}/system-colour-extra-bytes-black/src/Settings (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/src/Sources/Form1.tbform (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/src/Sources/Form1.twin (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/system-colour-extra-bytes-black.twinproj (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/vb6/Form1.frm (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/vb6/Module1.bas (100%) rename bugs/{ => filed}/system-colour-extra-bytes-black/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/textbox-text-ignores-maxlength/REPORT.md rename bugs/{ => filed}/textbox-text-ignores-maxlength/repro.json (89%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/src/Settings (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/src/Sources/Form1.tbform (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/src/Sources/Form1.twin (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/textbox-text-ignores-maxlength.twinproj (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/vb6/Form1.frm (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/vb6/Module1.bas (100%) rename bugs/{ => filed}/textbox-text-ignores-maxlength/vb6/Probe.vbp (100%) create mode 100644 bugs/filed/two-calling-conventions-accepted/REPORT.md rename bugs/{ => filed}/two-calling-conventions-accepted/repro.json (91%) rename bugs/{ => filed}/two-calling-conventions-accepted/src/Settings (100%) rename bugs/{ => filed}/two-calling-conventions-accepted/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/two-calling-conventions-accepted/two-calling-conventions-accepted.twinproj (100%) create mode 100644 bugs/filed/udt-watch-codegen-error/REPORT.md rename bugs/{ => filed}/udt-watch-codegen-error/repro.json (95%) rename bugs/{ => filed}/udt-watch-codegen-error/src/Settings (100%) rename bugs/{ => filed}/udt-watch-codegen-error/src/Sources/Startup.twin (100%) rename bugs/{ => filed}/udt-watch-codegen-error/udt-watch-codegen-error.twinproj (100%) delete mode 100644 bugs/import-no-settings-silent/repro.json delete mode 100644 bugs/static-ctor-args/repro.json diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index f30e3634f..a7e892b2b 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -464,41 +464,6 @@ What does not reproduce it: `import`'s project path and the printing commands ta --- -## `import` of a folder with no `Settings` file fails without saying why - -*DEFERRED until after v1* - -**Describe the bug** -Given a folder with no `Settings` file at its top, `import` lists the files it read, ends `... FAILED` with no `ERROR:` line, and writes nothing. Every other failure measured names its cause. The refusal is right, and the silence is not. - -**To Reproduce** -Steps to reproduce the behavior: -1. Unzip `import-no-settings-silent.zip` (it holds `import-no-settings-silent.twinproj`, an ordinary project) and export it: `twinBASIC_win32.exe export C:\p\import-no-settings-silent.twinproj C:\p\src\`. -2. Import only the folder `Sources`, which has no `Settings` file at its top: `twinBASIC_win32.exe import C:\p\x.twinproj C:\p\src\Sources\`. -3. See: - ``` - importing into "C:\p\x.twinproj" from "C:\p\src\Sources\"... - IMPORTED: C:\p\src\Sources\\Startup.twin - ... FAILED - ``` -4. See that there is no `ERROR:` line, the exit code is 0, and `C:\p\x.twinproj` was not written. - -**Expected behavior** -An `ERROR:` line that names the cause, such as `ERROR: no Settings file in the input folder`, before `... FAILED`, as the other failures print. A failure should also not exit with 0. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: minor, because the refusal is right. - -Also on BETA 983, where a copy of the HelloWorld sample's tree with `Settings` deleted behaves the same: it lists `Resources\ICON\twinBASIC.ico` and `Sources\HelloWorld.twin`, ends `... FAILED` and writes no project. - - - ---- - ## Export Project stops at a read-only file after deleting everything before it, and the IDE reports nothing *DEFERRED until after v1* @@ -590,55 +555,6 @@ What does not reproduce it: choosing **Ignore** without pressing a step key firs --- -## A `Static` in a Module procedure cannot be initialised with an argument constructor of a class declared later in the project - -*DEFERRED until after v1* - -**Describe the bug** -A `Static` variable in a Module procedure, initialised with `New` and a constructor that takes arguments, fails with TB5074 when the class is declared later in the project than the Module, in another file or further down the same file. With the class declared first, the same line compiles. `Dim` with the same initialiser compiles in both orders, so the declaration's position in the project changes whether a valid `Static` compiles. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `static-ctor-args.twinproj` (attached as `static-ctor-args.zip`). The project lists `Probe.twin`, which holds the Module, before `ProbeDog.twin`, which holds the class: - ``` - ' Probe.twin - Module Probe - Public Sub T() - Static s As Dog = New Dog("Rex") - Debug.Print s.GetName() - End Sub - End Module - - ' ProbeDog.twin - Private Class Dog - Private m_Name As String - Public Sub New(ByVal Name As String) - m_Name = Name - End Sub - Public Function GetName() As String - Return m_Name - End Function - End Class - ``` -2. See the project fail to compile with `TB5074 Could not bind to parameterized constructor of class 'Dog'. No compatible Sub New() method found`, at the `New`. -3. Move the `Dog` class into `Probe.twin`, above the Module: the project compiles with no error. - -What does not reproduce it, each measured with the class declared later than the Module unless it says otherwise: `Dim s As Dog = New Dog("Rex")` compiles, and so does `Static s As Dog` with `Set s = New Dog("Rex")`; a `Static` with a constructor that takes no arguments compiles; the same `Static` in a method of a Class compiles; and the same `Static` with the class declared first compiles, in a Module procedure, a Function, a Class method and a Property Get, in one file or two, with the class `Private` or `[COMCreatable(False)]`. The failure holds for a `Private` class and a public `[COMCreatable(False)]` one, and with a second constructor beside the one that takes arguments. - -**Expected behavior** -The declaration compiles whichever of the Module and the class comes first, as `Dim` does and as `Static` does when the class is first. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Also on BETA 983. Severity: a valid declaration does not compile, and which projects it affects depends on file order, so it appears and disappears as files are added. The workaround is a `Static` without an initialiser and a `Set` on first use, or declaring the class first. The order is the order of the files inside the `.twinproj`; the reproducer is packed with `Probe.twin` before `ProbeDog.twin`. - - - ---- - ## *Import from file...* leaves the imported package unticked *DEFERRED until after v1* @@ -835,13 +751,13 @@ Steps to reproduce the behavior: Private Interface IUnk Sub Dummy() End Interface - + Private Class RC Implements IUnk Private Sub IUnk_Dummy() Implements IUnk.Dummy End Sub End Class - + Module Startup Public Sub Main() Dim u As IUnk @@ -875,91 +791,6 @@ What was tried: --- -## A late-bound call that passes arguments and fails is issued a second time, without them - -**Describe the bug** -When a late-bound call that passes arguments fails, twinBASIC calls `Invoke` a second time, as a property read (`wFlags` 3) with no arguments. A `Sub` called with an argument it does not take therefore runs twice before error 13 is raised, a failed property assignment runs the property's `Property Get` afterwards, and the error the caller sees is replaced. Observed in a run of the reproducer project, and with `Invoke` implemented by a class that records its calls. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `latebound-call-retried.twinproj` (attached as `latebound-call-retried.zip`). Its one source file, `Startup.twin`, holds a class and `Sub Main`. `Widget` has `Sub Hello()`, which counts how often it runs, and a `Property Let Prop` that raises error 5 beside a `Property Get Prop` that counts how often it runs. -2. Run it. `Main` calls `o.Hello 1` and `o.Prop = 1` through `Dim o As Object = New Widget`, with `On Error Resume Next`, and prints the error and the counts after each: - ``` - o.Hello 1: error 13, ran 2 time(s), Property Get ran 0 - o.Boom 1: error 5, ran 1 time(s), Property Get ran 0 - x = o.BoomFn(1): error 5, ran 1 time(s), Property Get ran 0 - o.Prop = 1: error -2147352567, ran 1 time(s), Property Get ran 1 - w.Boom 1 (early bound): error 5, ran 1 time(s), Property Get ran 0 - ``` -3. See `Hello` run twice, and `Property Get Prop` run after the `Property Let` that failed. The error from the assignment is `&H80020009` (`DISP_E_EXCEPTION`) where the `Property Let` raised 5. -4. To see the second call, implement `IDispatch` in a `NotDispatchable` class whose `Invoke` prints `wFlags`, `pDispParams.cArgs` and whether `pVarResult` is null, and returns a failure code. With `Fail3` returning `DISP_E_TYPEMISMATCH` and `Fail5` raising error 5: - ``` - o.Fail3 1 Invoke flags=1 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set - o.Fail3 = 5 Invoke flags=4 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set - o.Fail5 = 5 Invoke flags=4 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set - Set o.Fail1 = e Invoke flags=8 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set - ``` - -**Expected behavior** -One `Invoke` per late-bound call, as a raw `Invoke` does: calling `Invoke` directly with the same argument runs `Hello` once and returns `DISP_E_TYPEMISMATCH`. In VB6 the same two statements fail without running anything the callee counts: `o.Hello 1` raises error 450, *Wrong number of arguments or invalid property assignment*, with `Hello` run 0 times, and `o.Prop = 1` raises error 5 from the `Property Let` with the `Property Get` run 0 times. The VB6 project is attached as `latebound-call-retried-vb6.zip`; it prints `o.Hello 1 -> error 450 (1C2) [Wrong number of arguments or invalid property assignment]` with `Hits=0`, and `o.Prop = 1 -> error 5 (5) [Invalid procedure call or argument]` with `Hits=1 Gets=0` (the one run is the `Property Let`). At the least a failed call must not run the callee again, and the error of the assignment must be the one the `Property Let` raised. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: a late-bound call with a wrong argument count has side effects twice, and a late-bound property assignment that fails calls the getter, which may be expensive or have effects of its own. - -What does not reproduce it: a statement with no arguments (`o.Fail3`, `o.Hello`, `x = o.Fail3`); a statement that raises from inside the callee (`o.Boom 1` and `x = o.BoomFn(1)` run once and keep error 5); `CallByName` with an argument, for every call type (one `Invoke`); a call that succeeds; early-bound calls. What does reproduce it: `o.Hello 1`, `o.Hello(1)`, `o.Hello 1, 2`, the same through a `Variant` holding the object, and `o.Fn 1` for a `Function` with no parameters (ran twice, error 13). An assignment is repeated whatever the failure was (`DISP_E_TYPEMISMATCH`, `DISP_E_MEMBERNOTFOUND`, `E_FAIL`, an error raised by the setter); a statement call is repeated after `DISP_E_TYPEMISMATCH`, but not after an error raised in the callee. - -The second call resembles VB's rule for `o.Member(args)` on a property that returns an object or a collection: read the property with no arguments, then apply the arguments to the result. It is applied after a failure of any call that has arguments. The single run of `Hello` in the raw `Invoke` case is also at odds with the COM contract, which gives `DISP_E_BADPARAMCOUNT` for too many arguments without running the member; it is left out of this entry. - - - ---- - -## A late-bound call to a member that does not exist raises &H80020006, and `CallByName` raises &H80004005, where VB6 raises 438 - -**Describe the bug** -Calling a member that an object does not have, through an `Object` variable, raises error `&H80020006` (`DISP_E_UNKNOWNNAME`, *Unknown name.*) instead of 438, *Object doesn't support this property or method*. `CallByName` with the same name raises `&H80004005`, *Unspecified error*. An `On Error` handler written for VB6 or VBA, which tests for 438, does not catch either. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `latebound-unknown-member-error.twinproj` (attached as `latebound-unknown-member-error.zip`). Its one source file, `Startup.twin`, holds a class `Widget` with a `Sub Hello()` and a `Sub Main` that calls members through `Object` variables, with `On Error Resume Next`. -2. Run it and read the DEBUG CONSOLE: - ``` - o.Nope -> error -2147352570 (80020006) [Unknown name.] - Collection.Nope -> error -2147352570 (80020006) [Unknown name.] - CallByName o, Nope -> error -2147467259 (80004005) [Unspecified error] - o.Hello (control) -> error 0 (0) [] - ``` - -**Expected behavior** -Error 438 for all three, as in VB6 and VBA. The same cases in a VB6 project (a class, a `Variant` holding it, a `Collection`, a `Scripting.Dictionary`, and `CallByName` on each of them), attached as `latebound-unknown-member-error-vb6.zip`: -``` -o.Nope (class) -> error 438 (1B6) [Object doesn't support this property or method] -v.Nope (class in a Variant) -> error 438 (1B6) [Object doesn't support this property or method] -Collection.Nope -> error 438 (1B6) [Object doesn't support this property or method] -Dictionary.Nope -> error 438 (1B6) [Object doesn't support this property or method] -CallByName class Nope -> error 438 (1B6) [Object doesn't support this property or method] -CallByName Dictionary Nope -> error 438 (1B6) [Object doesn't support this property or method] -CallByName Collection Nope -> error 438 (1B6) [Object doesn't support this property or method] -``` -twinBASIC already maps `DISP_E_MEMBERNOTFOUND` from `Invoke` to 438; `DISP_E_UNKNOWNNAME` from `GetIDsOfNames`, which is the answer for a name the object does not have, should be mapped the same way, and `CallByName` should not turn it into `E_FAIL`. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: code ported from VB6 or VBA that probes an object for a member with `On Error` and 438 stops working, and the message names a COM code instead of the problem. - -The twinBASIC class's `GetIDsOfNames` itself is right: it returns `DISP_E_UNKNOWNNAME` for a name it does not know, as the contract says; the conversion into a run-time error is what differs. The same `&H80020006` came from a twinBASIC class, a `Collection`, a `Scripting.Dictionary` and a `Scripting.FileSystemObject`, and a `Variant` holding an object; `CallByName` gave `&H80004005` for a twinBASIC class and a `Dictionary`. Other failures are mapped as VB6 does: a call that `Invoke` rejects with `DISP_E_MEMBERNOTFOUND` raises 438, and a `Nothing` object raises 91. - - - ---- - ## Calling a method of `stdole.IDispatch` is a late-bound call by name and fails with &H80020006 **Describe the bug** @@ -1008,47 +839,6 @@ What was tried: `GetIDsOfNames` and `Invoke` with zero arguments, and `GetTypeIn --- -## A twinBASIC class's `GetTypeInfo` with an `iTInfo` of 1 returns E_UNEXPECTED, not DISP_E_BADINDEX - -**Describe the bug** -`IDispatch::GetTypeInfo` on an object of a twinBASIC class returns `E_UNEXPECTED` (`&H8000FFFF`) for any `iTInfo` other than 0. The object's `GetTypeInfoCount` returns 1, so 0 is the only valid index, and the contract gives `DISP_E_BADINDEX` for a bad one. Observed in a run of the reproducer project, calling the method through a project's own declaration of the interface. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `gettypeinfo-badindex.twinproj` (attached as `gettypeinfo-badindex.zip`). Its one source file, `Startup.twin`, declares a copy of `IDispatch` with the same `[InterfaceId]` (the `stdole` declaration cannot be called), a class `Widget` with one `Sub`, and a `Sub Main` that calls `GetTypeInfoCount` and `GetTypeInfo` on a `Widget` through the copy. -2. Run it and read the DEBUG CONSOLE: - ``` - GetTypeInfoCount = 1 - GetTypeInfo(0): HRESULT 0, pointer returned True - GetTypeInfo(1): HRESULT 8000FFFF, error -2147418113, pointer returned False - GetTypeInfo(2): HRESULT 8000FFFF, error -2147418113, pointer returned False - GetTypeInfo(-1): HRESULT 8000FFFF, error -2147418113 - ``` - -**Expected behavior** -`DISP_E_BADINDEX` (`&H8002000B`), which the `IDispatch::GetTypeInfo` documentation gives for an index that is not valid, for an index the object does not have. `E_UNEXPECTED` (*Catastrophic failure*) is the code for a call made at a time when the object cannot take it, and it reads as a fault in the object. - -VB6 does not return `E_UNEXPECTED`. The same calls on a VB6 class, made through `DispCallFunc` (the VB6 project is attached as `gettypeinfo-badindex-vb6.zip`), return `TYPE_E_ELEMENTNOTFOUND` (`&H8002802B`) for every index but 0: -``` -GetTypeInfoCount = 1 (HRESULT 0) -GetTypeInfo(0): HRESULT 0, pointer returned True -GetTypeInfo(1): HRESULT 8002802B, pointer returned False -GetTypeInfo(2): HRESULT 8002802B, pointer returned False -GetTypeInfo(-1): HRESULT 8002802B, pointer returned False -``` -Either `DISP_E_BADINDEX` or VB6's `TYPE_E_ELEMENTNOTFOUND` says that the index names nothing; `E_UNEXPECTED` does not. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: minor. A caller that checks for `DISP_E_BADINDEX` to learn that an object has no more type information gets a different code; `GetTypeInfo(0)` and `GetTypeInfoCount` are right. - - - ---- - ## `Err.Raise` without a source or a description leaves `Source` empty, and the defaults are not VBA's **Describe the bug** @@ -1130,132 +920,6 @@ Severity: low. The slot is not a documented twinBASIC interface, but a program t --- -## `IConnectionPoint.Advise(Nothing)` on a twinBASIC class's connection point ends in an access violation - -**Describe the bug** -A class that declares an `Event` is a connectable object, and its connection point is reached through `IConnectionPointContainer`. Calling `Advise` on that connection point with a null sink raises a native `ACCESS_VIOLATION` and ends the run, instead of returning an error. It is the same crash whether the argument is `Nothing` or an unassigned `stdole.IUnknown` variable. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `advise-nothing-crashes.twinproj` (attached as `advise-nothing-crashes.zip`). Its one source file, `Startup.twin`, declares the project's own copies of `IConnectionPointContainer`, `IEnumConnectionPoints` and `IConnectionPoint` (`stdole` has none of them), a class with one event, and this: - ``` - Private Class Source - Public Event Ping() - End Class - - Public Sub Main() - Dim src As New Source - Dim container As IConnectionPointContainer = src - Dim points As IEnumConnectionPoints = container.EnumConnectionPoints() - Dim point As IConnectionPoint, fetched As Long - points.Next 1, point, fetched - Debug.Print "before" - Dim cookie As Long = point.Advise(Nothing) - Debug.Print "after " & cookie - End Sub - ``` -2. Run the project in the IDE (F5). -3. See `before` in the DEBUG CONSOLE, and then `NATIVE EXCEPTION: ACCESS_VIOLATION /Startup.twin; Startup.Main LINE 000033 [...twinBASIC_win32.dll+00389F3E]`. `after` is never printed. - -**Expected behavior** -`Advise` returns `E_POINTER`, which twinBASIC raises as run-time error `&H80004003` that `On Error` can handle. The Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) lists `E_POINTER` for "The value in *pUnkSink* or *pdwCookie* is not valid. For example, either pointer may be **NULL**." - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low in practice, since a program rarely advises a null sink, but a COM client written in any language can send one, and the connection point crashes the process that hosts the class instead of refusing the call. - -What was tried: an object that is not a sink gives an ordinary error (`E_NOINTERFACE`, `&H80004002`), so only a null pointer crashes. The address of the crash is the same in the standalone probe and in this project. - -VB6 crashes too. The same call on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-nothing-crashes-vb6.zip`), ends the exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`, in the Application event log) after it writes `before`. A fix therefore costs no compatibility: no program depends on the crash. - - - ---- - -## `Unadvise` with a cookie that names no connection succeeds, where VB6 returns CONNECT_E_NOCONNECTION - -**Describe the bug** -On the connection point of a twinBASIC class that has an `Event`, `Unadvise` with a cookie that names no connection (0 and 99 were tried) succeeds silently and does nothing. The COM contract returns an error, and VB6 returns `CONNECT_E_NOCONNECTION` (`&H80040200`). - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `advise-unadvise-hresults.twinproj` (attached as `advise-unadvise-hresults.zip`). Its one source file, `Startup.twin`, declares the project's own copies of the connection-point interfaces (`stdole` has none of them), a class `Source` with one event, and a class `NotASink` with one field. `Sub Main` runs, with `On Error Resume Next`: - ``` - cookie = point.Advise(sink) ' sink is a NotASink - point.Unadvise 0 - point.Unadvise 99 - ``` -2. Run the project in the IDE (F5). -3. See in the DEBUG CONSOLE: - ``` - Advise, a sink without the outgoing interface: error=80004002 - Unadvise 0: error=0 - Unadvise 99: error=0 - ``` - -**Expected behavior** -`Unadvise` reports the bad cookie, as VB6 does. The page for [IConnectionPoint::Unadvise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-unadvise) lists `E_POINTER` for "The value in *dwCookie* does not represent a valid connection", and `CONNECT_E_NOCONNECTION` is the code the `CONNECT_E_` range has for it. It is not `S_OK`. - -The same calls on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-unadvise-hresults-vb6.zip`): -``` -Advise, a sink without the outgoing interface: error=80004002 - cookie=-1 -Unadvise 99: error=80040200 -before Unadvise 0 -``` -`Unadvise 0` comes last because it ends the VB6 exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`). twinBASIC's 0 for that cookie is no worse than VB6's crash; the cookie 99 is the clean comparison. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low. A client that releases a connection twice, or with a wrong cookie, is told it worked. - -`Advise` with a sink that does not answer `QueryInterface` for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) says the method "must return CONNECT_E_CANNOTCONNECT" (`&H80040201`). VB6 returns `E_NOINTERFACE` too, so that is not reported here; the reproducer prints it as a control. A sink that is an ordinary twinBASIC class gives the same `E_NOINTERFACE` whether it has no members or implements `IDispatch` (the outgoing interface's identifier is generated for each build, so a twinBASIC class cannot answer for it). Nothing is connected afterwards, and no event reaches the sink. Calling `Unadvise` a second time with the cookie of the failed `Advise`, which is 0, also returns without an error. - - ---- - -## `New` on an `Interface` compiles, and a call on the object returns a default value, or ends in an access violation for an inherited member - -**Describe the bug** -`New` is accepted on an `Interface`, although no class exists for it, and the statement returns an object that is not `Nothing` and has no implementation behind its members. A call on a member the interface declares itself returns the default for its type (0 for a `Long`, an empty string, `Nothing` for an interface) and raises no error. A call on a member the interface inherits from another `Interface` ends the run with a native `ACCESS_VIOLATION`. `New stdole.IUnknown` and `New stdole.IDispatch` are refused with TB5074 (*Class construction expected class datatype*), so the check exists for an interface from a type library, and is missing for one declared in twinBASIC source, in a project or a package. Observed in a run of the reproducer project; found with `ErrorContext` of the VBRUN package, an interface with no class, and `ErrorCallstack` does the same. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `new-on-interface.twinproj` (attached as `new-on-interface.zip`). Its one source file, `Startup.twin`, declares `Interface IParent` with one `Function F() As Long` and `Interface IChild Extends IParent` with one `Function G() As Long`, and a `Sub Main` that constructs `New IParent`, `New ErrorContext` and `New IChild` and calls their members. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - IParent: Nothing? False, F() = 0 - ErrorContext: Unknown, Number = 0, Callstack Nothing? True - IChild.G() = 0 - IChild.F(), inherited, next - NATIVE EXCEPTION: ACCESS_VIOLATION /Startup.twin; Startup.Main LINE 000029 [$00000000] - ``` - The project compiles without a diagnostic. `ObjPtr` of each object is not 0. `ErrorContext.Number` and `.State` read 0, `.Description` and `.Source` read an empty string, and `.Callstack` is `Nothing`, so `e.Callstack.Count` raises error 91, as any call on `Nothing` does. That error 91 is not a second defect: an unhandled one ends a run, as it does for a `Collection` variable that is `Nothing`, and under `On Error Resume Next` it skips the statement. - -**Expected behavior** -A compile error, TB5074, for `New` on any `Interface`, as for `stdole.IUnknown`: an interface is a contract, and `New` needs a class that implements it. If an object is built nevertheless, a call on any of its members should behave the same way, not return a default for one member and crash on another. Code written against the VBRUN documentation, which lists `ErrorContext` with its members, compiles and then reads zeros, where a refusal would say at once that nothing can create the object. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low in practice, since few programs write `New` on an interface, but it is a compile-time check that is missing, and the result is a silent wrong value or a crash. - -What was tried, each in a project of its own: `New` is accepted on an interface declared with `Extends stdole.IUnknown`, with no `Extends`, with `Extends stdole.IDispatch`, with `Extends` another interface of the project, and on a `Private` one, and `Dim x As New IFoo` is accepted as well. `TypeName` gives `Unknown` for an interface that extends `stdole.IUnknown` (and for `ErrorContext` and `ErrorCallstack`), and the interface's own name for one with no `Extends` or with `Extends stdole.IDispatch`. A `String` member returns an empty string, a `Property Get` returns 0, a `Sub` returns without a sign, and a member returning the interface itself returns `Nothing`. Only inherited members crash. `New stdole.IUnknown` and `New stdole.IDispatch` are refused. The same results on BETA 983. - -VB6 has no comparison: a project cannot declare an `Interface`, and the interfaces of the type libraries it references (`stdole.IUnknown`, `IPictureDisp`, `IFontDisp`) are hidden from it (*User-defined type not defined*). - - - ---- - ## Clear All Breakpoints is undone by restarting the compiler, unless the project was saved in between **Describe the bug** @@ -1296,52 +960,6 @@ Severity: breakpoints a person has removed stop the program again after a restar --- -## A watch on a variable of a user-defined type fails with a codegen error, and the Debug Console reports a linker error at every stop - -**Describe the bug** -While the debugger is stopped, a watch on a variable of a user-defined type cannot be evaluated. **Watches** shows `(compile error: codegen error; check for compilation errors)`, of type `ERROR`, and the Debug Console prints `[LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1`. The linker error comes again at every later stop while the watch exists, and `? p` in the Debug Console fails the same way. **Variables** shows the same variable and its fields without trouble, and a watch on one of its fields works. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `udt-watch-codegen-error.twinproj` (attached as `udt-watch-codegen-error.zip`). `Startup.twin` declares a type, and `Main` passes the line marked `BREAK` twice: - ``` - Private Type Point - X As Long - Y As Long - End Type - - Public Sub Main() - Dim p As Point - Dim i As Long - p.X = 7 - For i = 1 To 2 - Debug.Print "pass " & i ' BREAK - Next - End Sub - ``` -2. Put a breakpoint on the line marked `BREAK` (F9) and press F5. The run stops there, and **Variables** shows `p` as `{user defined type, 8 bytes}`, with `X` and `Y`. -3. Add a watch on `p` (**Debug > Add Watch...**, or the plus sign in **Watches**). The watch shows `(compile error: codegen error; check for compilation errors)`, of type `ERROR`, and the Debug Console prints `[LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1`. -4. Press F5. The run stops at the breakpoint again, and the Debug Console prints the linker error again. -5. Type `? p` in the Debug Console and press Enter. It fails with the same error, and the Debug Console prints the same linker line. - -**Expected behavior** -The watch shows `p` as **Variables** does, `{user defined type, 8 bytes}` opening to its fields, as VBA's Watch window shows a variable of a user-defined type. An expression that cannot be shown, such as `? p`, which has no single value to print, is refused with a message about the expression. A linker error about generated code reads as though the project had failed to build, which it has not. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Also on BETA 987, identically. - -What does not reproduce it: a watch on a field, `p.X`, which shows `7`, of type `Variant [Long]`; and **Variables**, which shows `p` and its fields. That type suggests a watch is evaluated as a `Variant`, which cannot hold a value of a user-defined type. Seen with a `Private Type` declared in the module; other declarations of the type were not tried. - -Severity: a watch on a structure is unusable, and while it exists every stop adds a linker error to the Debug Console. - - - ---- - ## After Break Into Code stops in DoEvents, evaluating anything ends the program with an access violation **Describe the bug** @@ -1384,237 +1002,57 @@ Severity: high. Breaking into a loop that calls `DoEvents` is an everyday way to --- -## A system colour value with a nonzero second or third byte draws black since BETA 984, where VB6 takes the index from the low 16 bits +## The Debug Console offers no completion for a name typed after `?` and a space **Describe the bug** -Since BETA 984 (release note: "fixed: Circle/Line/PSet color value handling to match VB6's relaxed rules"), `Line`, `Circle` and `PSet` draw black for a colour whose top byte is `&H80` and whose second or third byte is not 0, such as `&H80FF000F` or `&H80010003`. The colour is read as a system colour only when the value minus `&H80000000` is 0 to 30, and any other `&H80` value draws black. VB6 takes the system colour index from the low 16 bits and ignores the bits in between, so it draws system colour 15 for `&H80FF000F` and system colour 3 for `&H80010003`. BETA 983 gave the same colours as VB6 for these two values. Observed by reading the pixel back with `GetPixel` from a form with `AutoRedraw` set. +The Debug Console's IntelliSense list offers no names after `?` and a space, the form a line in the console is usually typed in. With the run stopped at a breakpoint and `total`, a local, in scope, typing `? tot` opens no list at any key, and Ctrl+Space at the end of the line opens none either. Typed with no space, `?tot` opens the list at the `t`, and it offers `total`; so does `tot` at the start of the line. `Debug.Print tot` opens no list, as `? tot` does. **To Reproduce** Steps to reproduce the behavior: -1. Open `system-colour-extra-bytes-black.twinproj` (attached as `system-colour-extra-bytes-black.zip`). Its `Sub Main` loads `Form1`, sets `AutoRedraw`, `ScaleMode = vbPixels` and `BackColor = &H30201`, draws one point with `Form1.PSet (10, 10), Color` for each of three colours, and prints the pixel read back with `GetPixel`. -2. Run it (F5) and read the DEBUG CONSOLE: +1. Open `console-completion-after-print.twinproj` (attached as `console-completion-after-print.zip`). In `Startup.twin`, `Main` has two locals and calls a private Sub on the line marked `BREAK`: ``` - &H8000000F -> F0F0F0 - &H80FF000F -> 000000 - &H80010003 -> 000000 + Public Sub Main() + Dim total As Long + Dim items As New Collection + total = 5 + items.Add total + Touch total ' BREAK + End Sub + + Private Sub Touch(ByVal n As Long) + Debug.Print "total " & n + End Sub ``` +2. Put a breakpoint on the line marked `BREAK` (F9) and press F5. The run stops there. +3. Click in the Debug Console's input and type `? tot`. No list opens at any key. Press Ctrl+Space: no list opens. +4. Clear the input and type `?tot`, with no space. The list opens at the `t`, and once the line reads `?tot` it offers only `total`. Typing `tot` alone does the same. **Expected behavior** -The colours VB6 draws, which BETA 983 drew as well: -``` -&H8000000F -> F0F0F0 -&H80FF000F -> F0F0F0 -&H80010003 -> DBCDBF -``` -`F0F0F0` is system colour 15 (`vbButtonFace`) and `DBCDBF` is system colour 3 (`vbInactiveTitleBar`) on the machine of the observation. The VB6 project, attached as `system-colour-extra-bytes-black-vb6.zip`, prints exactly these three lines. +`? tot` opens the list with `total` in it, as `?tot` and `tot` do: the space does not change which names can follow the `?`. A `?` and a space is how a line is usually typed in the Debug Console, as in VBA's Immediate window, so that is where the list is wanted most. **Desktop:** - OS: Windows 10 Pro 22H2 (build 19045) - twinBASIC compiler version: BETA 995 **Additional context** -Severity: low. A value like these is not what a `vbButtonFace`-style constant holds, and the colours of VB6 forms and controls are always the plain `&H8000000F` form. The effect is a different colour for a value that VB6 accepts. - -BETA 983 drew the same colours as VB6 for `&H80FF000F` and `&H80010003`, by passing the value to `OleTranslateColor`. It also raised error 5 for a value that is neither a system colour nor an RGB value with a top byte of 0, 1 or 2, and 984 fixed that: `-1`, `&HFF0000FF`, `&H7FFFFFFF` and the `&H01` palette forms now draw as VB6 draws them (the low 24 bits), as do a `Double` such as 255.7 (256) and a `String` such as `"255"`. Only the `&H80` top byte differs from VB6. +Also on BETA 987, identically. -The same difference exists for the high end of the index range: VB6 draws `&H80000019` to `&H8000001E` black, and twinBASIC draws the system colour of that index (`&H8000001D` gives `FF9933`, `&H8000001E` gives `F0F0F0`); this looks deliberate, since the source names `COLOR_MENUBAR` (30) as the limit. `&H80000100` draws black in both BETA 995 and VB6, and `C8C8C8` (index 0) in BETA 983. +What does not reproduce it: `tot` after leading spaces, which opens the list with `total`; and a dot after `?` and a space, `? items.`, which opens the list with the `Collection`'s members, so only the names are missing there. With Ctrl+Space at the end of the line, `Touch tot`, an argument of a Sub call, opens the list with `total`, and `Call Tou` and `total = to` open none, so names are missing after `Call` and on the right of an assignment as well. Typed key by key, `Call Tou` goes wrong sooner, for a separate reason: the space after `Call` accepts the list's first entry, and the line becomes `CallByDispId()`. With no program running, Ctrl+Space gives the same results: `Mai` offers `Main`, and `? Mai` opens no list. -The change is `TranslateColor2` in the VB package's `Graphics.twin`, which `Line`, `Circle` and `PSet` call on `Color`: for a top byte of `&H80` it returns the system colour when the value minus `&H80000000` is 0 to 30 and 0 otherwise, and for any other value it returns the low 24 bits. +Severity: low to moderate. Name completion does not work in the form the console is nearly always typed in, and leaving out the space after `?` is the only way round it. - + --- -## Setting a colour property to a value that is not a colour stores it, and raises error 5 or nothing, where VB6 raises error 380 and keeps the old value +## A space typed in the Debug Console accepts the IntelliSense list's selection, so `Print x` becomes `Printerx` **Describe the bug** -Assigning a value that is not a colour to a colour property does not leave the property as it was, on a form, on a PictureBox or UserControl, on the Printer, and on every control tried. `Form.ForeColor = -1` and `Form.ForeColor = &H8000001F` raise error 5 (*Invalid procedure call or argument*) and still store the value, so `ForeColor` reads back `FFFFFFFF` and `8000001F` afterwards. `Form.BackColor = -1` and `Form.FillColor = -1` raise nothing and store the value. `ForeColor` raises error 5 on the surfaces that draw with a device context (Form, PictureBox, UserControl, Printer); every other colour property tried raises nothing, for `-1`, `&H8000001F` and `&HFF0000FF` alike. VB6 raises error 380 (*Invalid property value*) for every one of these assignments and keeps the colour the property had. Observed by assigning with `On Error Resume Next` and printing `Err.Number` and the value read back. +While the Debug Console's IntelliSense list is open, a space typed in its input accepts the list's first entry: the entry is written over the word before the caret, and the space is lost. The console's list never offers keywords, so a keyword that starts a longer name is replaced by that name. With the run stopped at a breakpoint, typing `Print total` gives `Printertotal`, because at `Print` the list offers `Printer`, `PrinterObjectConstants` and others, but not `Print`. In the same way, `Call Touch(total)` becomes `CallByDispId(Touch(total))`, and `Set obj = items` becomes `SetAttr(obj = items)`. The console refuses all three lines. **To Reproduce** Steps to reproduce the behavior: -1. Open `forecolor-invalid-value-stored.twinproj` (attached as `forecolor-invalid-value-stored.zip`). Its `Sub Main` loads `Form1`, which holds a PictureBox, a Label, a TextBox, a CommandButton and a user control, UC1, whose `RunCases` assigns its own colour properties; it then loads `MyMDIForm`, an MDIForm, assigns the same values to its `BackColor`, shows it and assigns them again. For each property tried it sets `vbGreen`, then assigns `-1` (and `&H8000001F` to the form's `ForeColor`) under `On Error Resume Next` and prints the error number and the value read back. The Printer lines need a default printer; nothing is printed. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - Form.ForeColor = -1: Err 5, reads FFFFFFFF - Form.ForeColor = &H8000001F: Err 5, reads 8000001F - Form.BackColor = -1: Err 0, reads FFFFFFFF - Form.FillColor = -1: Err 0, reads FFFFFFFF - PictureBox.ForeColor = -1: Err 5, reads FFFFFFFF - PictureBox.BackColor = -1: Err 0, reads FFFFFFFF - PictureBox.FillColor = -1: Err 0, reads FFFFFFFF - Label.ForeColor = -1: Err 0, reads FFFFFFFF - Label.BackColor = -1: Err 0, reads FFFFFFFF - TextBox.ForeColor = -1: Err 0, reads FFFFFFFF - TextBox.BackColor = -1: Err 0, reads FFFFFFFF - CommandButton.BackColor = -1: Err 0, reads FFFFFFFF - Printer.ForeColor = -1: Err 5, reads FFFFFFFF - Printer.FillColor = -1: Err 0, reads FFFFFFFF - UserControl.ForeColor = -1: Err 5, reads FFFFFFFF - UserControl.BackColor = -1: Err 0, reads FFFFFFFF - UserControl.FillColor = -1: Err 0, reads FFFFFFFF - MDIForm.BackColor = -1: Err 0, reads FFFFFFFF - MDIForm.BackColor = &H8000001F: Err 0, reads 8000001F - MDIForm (shown).BackColor = -1: Err 5, reads FFFFFFFF - MDIForm (shown).BackColor = &H8000001F: Err 5, reads 8000001F - ``` - -**Expected behavior** -Error 380 for each assignment, and the old value kept, as VB6 does (the VB6 project, attached as `forecolor-invalid-value-stored-vb6.zip`, prints): -``` -Form.ForeColor = -1: Err 380, reads FF00 -Form.ForeColor = &H8000001F: Err 380, reads FF00 -Form.BackColor = -1: Err 380, reads FF00 -Form.FillColor = -1: Err 380, reads FF00 -PictureBox.ForeColor = -1: Err 380, reads FF00 -PictureBox.BackColor = -1: Err 380, reads FF00 -PictureBox.FillColor = -1: Err 380, reads FF00 -Label.ForeColor = -1: Err 380, reads FF00 -Label.BackColor = -1: Err 380, reads FF00 -TextBox.ForeColor = -1: Err 380, reads FF00 -TextBox.BackColor = -1: Err 380, reads FF00 -CommandButton.BackColor = -1: Err 380, reads FF00 -Printer.ForeColor = -1: Err 380, reads FF00 -Printer.FillColor = -1: Err 380, reads FF00 -UserControl.ForeColor = -1: Err 380, reads FF00 -UserControl.BackColor = -1: Err 380, reads FF00 -UserControl.FillColor = -1: Err 380, reads FF00 -MDIForm.BackColor = -1: Err 380, reads FF00 -MDIForm.BackColor = &H8000001F: Err 380, reads FF00 -MDIForm (shown).BackColor = -1: Err 380, reads FF00 -MDIForm (shown).BackColor = &H8000001F: Err 380, reads FF00 -``` -`FF00` is `vbGreen`, which each property held before the assignment. A program that sets a colour from user input and handles the error then keeps the colour it had. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low. `ForeColor` raises the error on the surfaces and the other properties do not, and the stored value is what a later `Line`, `Circle`, `PSet` or `Print` then uses (or what the control paints with), so a handled error leaves the invalid colour in force. - -What was tried: the same lines on BETA 983 print the same, so this is not a change of BETA 984. A value that is a valid colour, such as `&H100FF00` or `&H20000FF`, is accepted by `ForeColor` with no error, as in VB6. Besides the attached project, each of `-1`, `&H8000001F` and `&HFF0000FF` was assigned to every colour property of a UserControl (placed on the form), CheckBox, Frame, ListBox, Shape, ComboBox, DirListBox, DriveListBox, FileListBox, OptionButton and OLE in a project of its own, on BETA 995 and 983 and in VB6: the results are the same as above, error 5 for the UserControl's `ForeColor` and none for the rest in twinBASIC, error 380 and the old value kept in VB6. The twinBASIC-only controls behave the same (`QRCode.ForeColor` raises error 5, `CheckMark.BackColor` and `MultiFrame.BackColor` raise nothing), and so do `CommandButton.ForeColor` and the Data control's colours, which VB6 has no counterpart or no loadable project for. The package source explains the pattern: the colour properties are plain stored fields (`Public ForeColor As OLE_COLOR` and so on, in `GraphicsBase` for Form, PictureBox, UserControl, PropertyPage and Report, and declared again in each control), with nothing that validates the value; the error 5 comes from the `ForeColor` change handler of `GraphicsBase`, which runs after the value is stored. - - - ---- - -## `TextBox.Text` assigned in code is not limited by `MaxLength`, where VB6 truncates it - -**Describe the bug** -With `MaxLength` set to 3, `Text1.Text = "abcdef"` stores all six characters and `Len(Text1.Text)` is 6. VB6 truncates the new text to `MaxLength` characters: `Text` reads `abc`. `SelText` is affected in the same way: with `Text` equal to `"ab"` and the caret at the end, assigning `SelText = "cdef"` leaves `abcdef` in twinBASIC and `abc` in VB6. The `Text` property setter of the VB package sends `WM_SETTEXT` to the edit control, and the whole string is stored. Observed by reading `Text` back. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `textbox-text-ignores-maxlength.twinproj` (attached as `textbox-text-ignores-maxlength.zip`). Its `Form1` holds one `TextBox`, `Text1`, and its `Sub Main` loads the form, sets `Text1.MaxLength = 3`, assigns `Text` and `SelText`, and prints what `Text` holds. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - Text = "abcdef": [abcdef] Len 6 - SelText = "cdef" after "ab": [abcdef] Len 6 - ``` - -**Expected behavior** -The text is cut to `MaxLength` characters, as in VB6 (the VB6 project, attached as `textbox-text-ignores-maxlength-vb6.zip`, prints): -``` -Text = "abcdef": [abc] Len 3 -SelText = "cdef" after "ab": [abc] Len 3 -``` -A program that relies on `MaxLength` to bound a field, for instance one that stores `Text` in a fixed-size record, gets a string longer than the limit. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low to medium. Typing into the box was not tried; the gap is the assignment in code. - -What was tried: BETA 983 prints the same two lines. Assigning the same long text a second time raises no `Change` event, in twinBASIC and in VB6. - - - ---- - -## `LSet` and `RSet` on a `Variant` destination raise 13 for `Null` and for an object, and pad a `Boolean` as `-1` - -**Describe the bug** -When the destination of `LSet` or `RSet` is a `Variant`, three subtypes behave differently from VB6. A `Variant` holding `Null` raises error 13 (*Type mismatch*), where VB6 raises 94 (*Invalid use of Null*). A `Variant` holding an object, such as a `Collection`, raises error 13, where VB6 raises no error. A `Variant` holding `True` is aligned as the two characters `-1`: `RSet v = "ab"` leaves `[ab]` with `Len` 2, where VB6 aligns within `"True"` and leaves `[ ab]` with `Len` 4. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `lset-variant-destination-types.twinproj` (attached as `lset-variant-destination-types.zip`). Its one source file, `Startup.twin`, has a `Sub Main` under `On Error Resume Next` that runs `LSet v = "abc"` on a `Variant` holding `Null`, `LSet v = "abc"` on one holding `New Collection`, and `RSet v = "ab"` on one holding `True`. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - LSet, destination Null: error 13 - LSet, destination Object: error 13 - RSet, destination True: [ab] Len 2 - ``` - -**Expected behavior** -`error 94` for `Null`, no error for the object, and `[ ab] Len 4` for `True`, as in VB6 (attached as `lset-variant-destination-types-vb6.zip`, which prints `error 94`, `error 0` and `[ ab] Len 4`). - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low. Programs rarely align into a `Variant` that holds `Null` or an object, and the error number matters only to a handler that tests for 94. - -A `Variant` holding an `Integer`, a `Long`, a `Double` or a `Date` is aligned within its text as in VB6, and one holding an error value or an array raises 13 in both. A `Null` source into a `String` destination raises 94 in both. The same results on BETA 983. - - - ---- - -## The Debug Console offers no completion for a name typed after `?` and a space - -**Describe the bug** -The Debug Console's IntelliSense list offers no names after `?` and a space, the form a line in the console is usually typed in. With the run stopped at a breakpoint and `total`, a local, in scope, typing `? tot` opens no list at any key, and Ctrl+Space at the end of the line opens none either. Typed with no space, `?tot` opens the list at the `t`, and it offers `total`; so does `tot` at the start of the line. `Debug.Print tot` opens no list, as `? tot` does. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `console-completion-after-print.twinproj` (attached as `console-completion-after-print.zip`). In `Startup.twin`, `Main` has two locals and calls a private Sub on the line marked `BREAK`: - ``` - Public Sub Main() - Dim total As Long - Dim items As New Collection - total = 5 - items.Add total - Touch total ' BREAK - End Sub - - Private Sub Touch(ByVal n As Long) - Debug.Print "total " & n - End Sub - ``` -2. Put a breakpoint on the line marked `BREAK` (F9) and press F5. The run stops there. -3. Click in the Debug Console's input and type `? tot`. No list opens at any key. Press Ctrl+Space: no list opens. -4. Clear the input and type `?tot`, with no space. The list opens at the `t`, and once the line reads `?tot` it offers only `total`. Typing `tot` alone does the same. - -**Expected behavior** -`? tot` opens the list with `total` in it, as `?tot` and `tot` do: the space does not change which names can follow the `?`. A `?` and a space is how a line is usually typed in the Debug Console, as in VBA's Immediate window, so that is where the list is wanted most. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Also on BETA 987, identically. - -What does not reproduce it: `tot` after leading spaces, which opens the list with `total`; and a dot after `?` and a space, `? items.`, which opens the list with the `Collection`'s members, so only the names are missing there. With Ctrl+Space at the end of the line, `Touch tot`, an argument of a Sub call, opens the list with `total`, and `Call Tou` and `total = to` open none, so names are missing after `Call` and on the right of an assignment as well. Typed key by key, `Call Tou` goes wrong sooner, for a separate reason: the space after `Call` accepts the list's first entry, and the line becomes `CallByDispId()`. With no program running, Ctrl+Space gives the same results: `Mai` offers `Main`, and `? Mai` opens no list. - -Severity: low to moderate. Name completion does not work in the form the console is nearly always typed in, and leaving out the space after `?` is the only way round it. - - - ---- - -## A space typed in the Debug Console accepts the IntelliSense list's selection, so `Print x` becomes `Printerx` - -**Describe the bug** -While the Debug Console's IntelliSense list is open, a space typed in its input accepts the list's first entry: the entry is written over the word before the caret, and the space is lost. The console's list never offers keywords, so a keyword that starts a longer name is replaced by that name. With the run stopped at a breakpoint, typing `Print total` gives `Printertotal`, because at `Print` the list offers `Printer`, `PrinterObjectConstants` and others, but not `Print`. In the same way, `Call Touch(total)` becomes `CallByDispId(Touch(total))`, and `Set obj = items` becomes `SetAttr(obj = items)`. The console refuses all three lines. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `console-space-accepts-completion.twinproj` (attached as `console-space-accepts-completion.zip`). In `Startup.twin`, `Main` has three locals and calls a private Sub on the line marked `BREAK`: +1. Open `console-space-accepts-completion.twinproj` (attached as `console-space-accepts-completion.zip`). In `Startup.twin`, `Main` has three locals and calls a private Sub on the line marked `BREAK`: ``` Public Sub Main() Dim total As Long @@ -1624,7 +1062,7 @@ Steps to reproduce the behavior: items.Add total Touch total ' BREAK End Sub - + Private Sub Touch(ByVal n As Long) Debug.Print "total " & n End Sub @@ -1651,652 +1089,3 @@ In the default mode, MODERN, the console asks for the list after every key and a Severity: moderate. `Print`, `Call` and `Set` begin lines that are often typed in the console, and each becomes a line the console refuses. The only ways round it are Escape before the space and another IntelliSense mode. - ---- - -## A `[RunAfterBuild]` Sub does not run, and nothing says so, when a module is named like the project - -**Describe the bug** -When a module has the same name as the project, a `[RunAfterBuild]` Sub does not run, and nothing says so. The build succeeds and the exe is written. The DEBUG CONSOLE's last line is `[BUILD] Executing '..'...`, and nothing follows it: no message box, no entry in the error panel, no diagnostic, and the status bar is as it is after a run that works. The names are compared without regard to letter case, so `ProbeWS` and `probews` clash as well. Even an empty module with the project's name, beside the module that holds the Sub, stops it. Observed in the DEBUG CONSOLE after pressing Build. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `module-named-like-project.twinproj` (attached as `module-named-like-project.zip`). The project is named `ModuleNamedLikeProject` and has one module of that name: - ``` - Module ModuleNamedLikeProject - Public Sub Main() - End Sub - [RunAfterBuild] - Public Sub Hello() - Debug.Cls - Debug.Print "RunAfterBuild ran" - End Sub - End Module - ``` -2. Build the project. -3. Read the DEBUG CONSOLE. It ends at `[BUILD] Executing 'ModuleNamedLikeProject.ModuleNamedLikeProject.Hello'...`. `RunAfterBuild ran` is never printed. -4. Rename the module, to `Runner` for example, and build again. The console is cleared and holds `RunAfterBuild ran`. - -**Expected behavior** -The Sub runs whatever the modules are called, as it does once the module is renamed. If a module may not have the project's name, the compiler should refuse it with an error, as VB6 does, and not build the project and skip the Sub in silence. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically. - -VB6 refuses a project whose module has the project's name. Its build stops with `Name conflicts with existing module, project, or object library`, for a module that holds `Sub Main` (the project is attached as `module-named-like-project-vb6.zip`) and for an empty module of that name beside the module that holds `Sub Main` (checked, not attached). So VB6 reports the clash, where twinBASIC accepts it and skips the Sub. - -What does not reproduce it: with any other module name, such as `Runner`, the Sub runs. A project with no module of the project's name runs it, whichever module holds it. - -What reproduces it besides the plain case: the clash in another letter case, such as a project `ProbeWS` with a module `probews`; and an empty module with the project's name in a project whose `[RunAfterBuild]` Sub is in a different module, `Runner`, where the console ends at `[BUILD] Executing 'ProbeWS.Runner.'...`, which names an unambiguous Sub. So the cause is the module's name, not a call that cannot be told apart. - -Severity: moderate. A `[RunAfterBuild]` Sub runs after the exe is built, for example to sign it, and here it is skipped while the build reports success. Nothing points at the module's name as the cause, so the project's owner has to find it by renaming modules. - - - ---- - -## A `[RunAfterBuild]` Sub does not run, and nothing says so, when its module holds a procedure named like the module - -**Describe the bug** -When a module holds a procedure with the same name as the module, a `[RunAfterBuild]` Sub in that module does not run, and nothing says so. The build succeeds and the exe is written. The DEBUG CONSOLE's last line is `[BUILD] Executing '..'...`, and nothing follows it: no message box, no entry in the error panel, no diagnostic. Not even the first statement of the Sub runs. The names are compared without regard to letter case, so `Runner` and `runner` clash. A Sub, a Function and a `Private` Sub all cause it, and the procedure does not have to be called. Observed in the DEBUG CONSOLE after pressing Build. - -This is a sibling of "A `[RunAfterBuild]` Sub does not run, and nothing says so, when a module is named like the project". There the module and the project share a name; here the project's name plays no part, and the clash is inside one module. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `sub-named-like-module.twinproj` (attached as `sub-named-like-module.zip`). The project is named `SubNamedLikeModule`. It has a `Startup` module with an empty `Sub Main`, and this module: - ``` - Module Probe - [RunAfterBuild] - Public Sub Hello() - Debug.Cls - Debug.Print "RunAfterBuild ran" - End Sub - - Public Sub Probe() - End Sub - End Module - ``` -2. Build the project. -3. Read the DEBUG CONSOLE. It ends at `[BUILD] Executing 'SubNamedLikeModule.Probe.Hello'...`. `RunAfterBuild ran` is never printed. -4. Rename `Sub Probe` to `NotProbe`, or move it to another module, and build again. The console is cleared and holds `RunAfterBuild ran`. - -**Expected behavior** -The Sub runs whatever the other procedures in its module are called, as it does once `Probe` is renamed. If a procedure may not have its module's name, the compiler should refuse it with an error, and not build the project and skip the Sub in silence. Writing `Probe.Probe` in code is already refused, with TB5027. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 983, identically, so this is not a recent regression. - -VB6 accepts the name. A project named `SubNamedLikeModule` with a standard module `Probe` (`Attribute VB_Name = "Probe"`) that holds `Public Sub Probe()` builds, and `Sub Main` calls `Probe` and it runs (the project is attached as `sub-named-like-module-vb6.zip`; it printed `Sub Probe ran, in a module named Probe`). VB6 has no `[RunAfterBuild]`, so what is checked is that the module and the Sub can coexist. - -What does not reproduce it: a procedure with another name, such as `NotProbe`. A procedure named like a module, when the module that holds it is a different one from the module that holds the `[RunAfterBuild]` Sub (a `Runner` module holding the Sub, and an `Other` module holding `Sub Other`). - -What reproduces it besides the plain case: a `Function Probe`, a `Private Sub Probe`, and the clash in another letter case (`Module Runner` with `Sub runner`). The failure needs no help from any tool: the Sub is the one marked `[RunAfterBuild]`, and its body never starts. - -Severity: moderate. A `[RunAfterBuild]` Sub runs after the exe is built, for example to sign it, and here it is skipped while the build reports success. Nothing points at the clash as the cause, so the project's owner has to find it by renaming procedures. - - - ---- - -## A declaration that names two calling conventions compiles without a diagnostic - -**Describe the bug** -An API declaration, a procedure, a delegate or an interface member can name its calling convention after its name, and the compiler accepts two conventions on one declaration, in either order, with no error or warning. The call then uses one of them. In every pair tried on a `Declare`, `CDecl` is dropped and the other keyword is used, whichever comes first. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `two-calling-conventions-accepted.twinproj` (attached as `two-calling-conventions-accepted.zip`). Its `Startup.twin` declares `RtlUlongByteSwap`, which `ntdll` exports as fastcall on a 32-bit build (its one argument in ECX), under several conventions: - ``` - Private Declare PtrSafe Function SwapFast FastCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - Private Declare PtrSafe Function SwapCdecl CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - Private Declare PtrSafe Function SwapFastCdecl FastCall CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - Private Declare PtrSafe Function SwapCdeclFast CDecl FastCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - Private Declare PtrSafe Function SwapThisCdecl ThisCall CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - Private Declare PtrSafe Function SwapCdeclThis CDecl ThisCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long - ``` - It also holds a delegate (`CDecl FastCall`), a procedure in a module (`CDecl ThisCall`) and an interface member (`ThisCall CDecl`), each naming two conventions. `Sub Main` prints the result of each `Declare` for `&H11223344`. -2. Compile it. The Problems panel reports 0 errors and 0 warnings. -3. Run it (win32). The DEBUG CONSOLE prints: - ``` - FastCall: 44332211 - FastCall CDecl: 44332211 - CDecl FastCall: 44332211 - ThisCall CDecl: 44332211 - CDecl ThisCall: 44332211 - CDecl: 1EEC6F00 - ``` - The last value changes from run to run: called as cdecl, the function swaps whatever ECX held. Every pair is called as fastcall or thiscall, which pass this one argument the same way. - -**Expected behavior** -A declaration has one calling convention, so the second keyword is refused with an error that names it. As it is, nothing says which of the two the call uses, and when one of them is `CDecl`, it is dropped without a word. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically apart from the cdecl value. - -Only the `Declare` pairs are observed at run time. The delegate, the procedure and the interface member are only compiled, so which convention they use is not known. Not tried: `FastCall` with `ThisCall`, which this function cannot tell apart, and a 64-bit build, where the conventions have no effect. - -Severity: low. It takes a mistake to trigger it, but the mistake goes unreported, and a declaration meant as cdecl that names a second convention by accident calls the function the wrong way. - - - ---- - -## `raiseEvent` in a tool window's own HTML throws a TypeError, and the add-in's listener is not called - -**Describe the bug** -An add-in sets a tool window's HTML with an inline handler that calls `raiseEvent`, as the list-view items of Sample 15 do, and registers a listener for the event with `AddEventListener` on the parent element. Clicking the element does not call the listener, and the add-in is told nothing. The IDE's page throws `TypeError: Cannot read properties of null (reading 'rootEventHandler')`: `raiseEvent` goes up through `parentNode` looking for an element that has `rootEventHandler`, and in plain tool-window HTML no element has one, so it goes past the document. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `raiseevent-plain-html-typeerror.twinproj` (attached as `raiseevent-plain-html-typeerror.zip`). It is an add-in, and its tool window is built like this: - ``` - With Pane.RootDomElement.ChildDomElements.Add("box", "div") - .AddEventListener("myEvent", AddressOf MyEvent) - .Properties.innerHTML = _ - "[raise] " & _ - "[direct]" - End With - ``` - `MyEvent` prints `myEvent from ` to the DEBUG CONSOLE. -2. Build it (win32). Copy `Build\RaiseeventPlainHtmlTypeerror_win32.dll` into `%APPDATA%\twinBASIC\addins\win32\`, restart the IDE and open any project. -3. Click the toolbar button **raiseEvent repro**. A tool window opens with **[raise]** and **[direct]**. -4. Click **[raise]**. Nothing is printed, and the IDE's page has the uncaught `TypeError` above. -5. Click **[direct]**. The DEBUG CONSOLE prints `myEvent from direct`. - -**Expected behavior** -Step 4 prints `myEvent from raise`: `raiseEvent` calls the listener the div registered, as calling it directly does in step 5. If `raiseEvent` is meant only for list views and Monaco widgets, it stops at the document without throwing. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995. - -The workaround is step 5's: an inline handler that calls the function `AddEventListener` stores on the element under the event's name. - -Severity: low. A workaround exists, but nothing tells the add-in's author why the listener is not called: the exception shows only in the IDE's own page. - - - ---- - -## Setting a property whose name starts with `on` through `HtmlElementProperties` does nothing, and raises no error - -**Describe the bug** -An add-in that sets an event-handler property of a tool-window element through `HtmlElementProperties`, such as `.Properties.onclick = "..."`, gets no error, and the element has no handler: a click does nothing, and the element has neither an `onclick` property nor an `onclick` attribute. The same handler written inline in `innerHTML` runs. The IDE page's property setter skips any property whose last name starts with `on`, with an empty branch (`if(a.indexOf("on")==0){}`), and reports success. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `on-property-ignored.twinproj` (attached as `on-property-ignored.zip`). It is an add-in, and its tool window is built like this: - ``` - With .Add("property", "div") - .Properties.innerText = "[property]" - On Error Resume Next - .Properties.onclick = "this.innerText = 'clicked'" - Host.DebugConsole.PrintText "onclick set, error " & Err.Number - On Error GoTo 0 - End With - With .Add("inline", "div") - .Properties.innerHTML = "[inline]" - End With - ``` -2. Build it (win32). Copy `Build\OnPropertyIgnored_win32.dll` into `%APPDATA%\twinBASIC\addins\win32\`, restart the IDE and open any project. -3. Click the toolbar button **on property repro**. A tool window opens with **[property]** and **[inline]**, and the DEBUG CONSOLE prints `onclick set, error 0`. -4. Click **[property]**. Nothing happens. -5. Click **[inline]**. Its text changes to `clicked`. - -**Expected behavior** -Either the property is set, as every other property is, and step 4 changes the text; or, if handler properties are refused on purpose, the assignment raises an error that says so, and step 3 prints a nonzero error. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995. - -Only `onclick` was tried. The setter's test is on the name, so it applies to every name that starts with `on`. - -Severity: low. `AddEventListener` and inline handlers work, but an add-in that sets a handler property gets neither the handler nor an error. - - - ---- - -## LLVM-compiled code raises no error when a `Double` overflows, where it raises error 6 otherwise - -**Describe the bug** -In a procedure compiled with LLVM, a `Double` addition, subtraction, multiplication or exponentiation whose result is too large for a `Double` raises no error: `Err.Number` stays 0 and the target holds infinity. The same statement in a procedure compiled without LLVM raises error 6 (*Overflow*), as VB6 does. A program that relies on error 6 to detect an overflow gets a silent `Inf` instead. No project setting and no other attribute is involved: the one difference is `[CompilerOptions("+llvm")]` on the procedure, or `tbrun --llvm` on the whole project. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `llvm-double-overflow-no-error.twinproj` (attached as `llvm-double-overflow-no-error.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its attribute. `PlainCases` holds the same statements without it. Each runs under `On Error Resume Next`, for example: - ``` - [CompilerOptions("+llvm")] - Private Sub LlvmCases() - Dim big As Double = 1E+308 - Dim r As Double - On Error Resume Next - r = 5#: Err.Clear: r = big * 10# - Debug.Print "LLVM Double * Err=" & Err.Number & " r=" & r - End Sub - ``` -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - plain Double * Err=6 r=1.#INF - plain Double + Err=6 r=1.#INF - plain Double ^ Err=6 - plain Double / Err=6 r=1.#INF - plain 1# / 0# Err=11 r=1.#INF - plain Long + Err=6 l=5 - LLVM Double * Err=0 r=1.#INF - LLVM Double + Err=0 r=1.#INF - LLVM Double ^ Err=0 - LLVM Double / Err=6 r=5 - LLVM 1# / 0# Err=11 r=5 - LLVM Long + Err=6 l=5 - ``` - -**Expected behavior** -Error 6 in LLVM-compiled code for every `Double` overflow, as in the same code without LLVM and in VB6 (attached as `llvm-double-overflow-no-error-vb6.zip`, which prints `Err=6` for the multiplication, the addition, the exponentiation and the division, and `Err=11` for `1# / 0#`). - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically, and on win32 and win64. Running the reproducer with `--llvm` compiles every procedure with LLVM and gives the `LLVM` results for both halves. With `[FloatingPointErrorChecks(True)]` on the LLVM procedure the multiplication still raises nothing. - -What does not reproduce it: a `Double` division that overflows (`1E+308 / 0.1`) raises 6, and so does `Exp(1000#)`, a `Long` overflow (`2147483647 + 1`) raises 6, and `1# / 0#` raises 11, all in LLVM-compiled code. So the overflow check is missing from the addition, subtraction, multiplication and `^` operators, not from the error path. - -A second difference shows in the same output. After a division that raises an error, LLVM-compiled code leaves the target unassigned: `r` is still 5 after `1# / 0#` (error 11) and after `1E+308 / 0.1` (error 6). Without LLVM the target is assigned infinity, `1.#INF`, and so it is in VB6 (`r=1.#INF` after each of its errors). Under `On Error Resume Next` the two builds then continue with different values. - -A `Single` addition, subtraction, multiplication or division that overflows raises no error without LLVM, where VB6 raises 6 (with LLVM the first three raise none either, and the division raises 6). That is a separate defect, with its own reproducer, in the entry "A `Single` that overflows raises no error, where VB6 raises error 6" (`bugs/single-overflow-no-error/`). - -Severity: a silent wrong result. An overflow that raises 6 in a debug run and no error in the LLVM-compiled build changes what a program computes, and nothing marks the difference. - - - ---- - -## A `Single` that overflows raises no error, where VB6 raises error 6 - -**Describe the bug** -When a `Single` addition, subtraction, multiplication or division produces a result too large for a `Single`, twinBASIC raises no error and the target holds infinity: `s = sbig + sbig` with `sbig` at 3E+38 leaves `Err.Number` at 0 and `s` at `1.#INF`. VB6 raises error 6 (*Overflow*) for the same statement. The same holds for `+=` and `*=`, for `Single * Long` and for some `Single` constant expressions. A `Double` overflow raises 6, so a `Single` and a `Double` are treated differently. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `single-overflow-no-error.twinproj` (attached as `single-overflow-no-error.zip`). Its `Sub Main` runs each case under `On Error Resume Next`, for example: - ``` - Dim sbig As Single = 3E+38! - Dim s As Single - On Error Resume Next - s = 5!: Err.Clear: s = sbig + sbig - Debug.Print "Single + Err=" & Err.Number & " s=" & s - ``` -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - Single + Err=0 s=1.#INF - Single - Err=0 s=-1.#INF - Single * Err=0 s=1.#INF - Single / Err=0 s=1.#INF - Single * Long Err=0 s=1.#INF - s = s + x Err=0 s=1.#INF - s += x Err=0 s=1.#INF - s = s * x Err=0 s=1.#INF - s *= x Err=0 s=1.#INF - CSng(Double) Err=6 s=5 - Single ^ Err=6 s=5 - Variant * Err=0 Double - Variant + Err=0 Double - ``` - -**Expected behavior** -Error 6 for every `Single` arithmetic result that does not fit a `Single`, as in VB6 (attached as `single-overflow-no-error-vb6.zip`, which prints `Err=6 s=1.#INF` for `+`, `-`, `*`, `/`, `Single * Long`, `s = s + x` and `s = s * x`). The target is `1.#INF` after the error in both, so only the error is missing. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically, and on win64 (which prints `inf` where win32 prints `1.#INF`). Compiled with LLVM, `+`, `-`, `*` and the compound forms are the same, and `/` raises 6, as in VB6. - -What matches VB6: a `Variant` holding a `Single` that overflows becomes a `Double` variant, with no error, in both (`Variant *`, `Variant +` above). `CSng` of a `Double` that is too large raises 6 in both. The result of `Single * Long` is a `Double` in both (`TypeName`), though twinBASIC stores it into a `Single` without an error. - -Where twinBASIC differs from VB6 in the other direction: `s = sbig ^ 2!` raises 6 in twinBASIC and gives `1.#INF` with no error in VB6, and so does assigning a `Double` that is too large to a `Single` (`s = 1E+300`: error 6 in twinBASIC, `1.#INF` with no error in VB6). - -A `Single` constant expression that overflows is refused by VB6 at compile time with *Overflow*. twinBASIC compiles some forms (`Const A As Single = 3E+38! * 10!`, and the `+`, `-` and `/` forms, in a project of their own) and refuses others with TB5002 (`^`, `CSng(1E+300)`, and `1E+300` assigned to a `Single`); which constants it refuses also changed with the other constants in the project, so that part is not in the reproducer. - -Severity: a silent wrong result. A `Single` overflow that ends the program or reaches a handler in VB6 gives `Inf` and carries on in twinBASIC. - - - ---- - -## A module-level array `Const` with an element that does not fit its type crashes the compiler - -**Describe the bug** -An array `Const` at module level whose element is too large for the element type takes the compiler down: `Const Big() As Long = Array(2147483648)`. The IDE's compile restarts the compiler, which crashes again, and the project can neither be compiled nor run. The crash happens whether or not the constant is used. Observed with `tbbuild`, which reports "the compiler crashed 3x". - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `array-const-element-overflow-crash.twinproj` (attached as `array-const-element-overflow-crash.zip`). Its one module is: - ``` - Module Startup - Const Big() As Long = Array(2147483648) - Public Sub Main() - End Sub - End Module - ``` -2. Compile it. The compiler crashes. - -**Expected behavior** -A compile error, as for a scalar constant: `Const K As Long = 2147483648` is refused with TB5002, and `Const Small() As Integer = Array(32768)` with TB5001. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -BETA 995 has no array constants: every array `Const` is refused there with TB5245, *array cannot be initialized here*. VB6 has no array constants either (`Const A() As Long = Array(1)` is a syntax error), so there is no VB6 project. - -What crashes, each in a module of its own, unused or used: `Array(2147483648)`, `Array(2147483648#)`, `Array(3000000000.5)` and `Array(1, 2147483648)` for `Long`; `Array(40000.5)` for `Integer`; `Array(300.5)` and `Array(-1.5)` for `Byte`; `Array(9223372036854775808#)` for `LongLong`. What does not: the same constant declared inside a procedure compiles clean; a `Single` or `Double` array with a huge element (`Array(1E+300)`) compiles; an `Integer` or `Byte` element that is an integer literal too large (`Array(32768)`) is the ordinary TB5001. - -A second crash seems to belong with this one, and it is not in the reproducer. A `Single` array `Const` whose element overflows, `Const A() As Single = Array(1E+39)`, compiles alone, but crashes the compiler when a later array `Const` of `Double` calls `Sqr`, `Timer` or `Rnd`, for example `Const B() As Double = Array(Sqr(4))` on the next line. The reverse order, `Abs` or `Len` in place of those, `Now` or `Date`, and a `Single` array of `Timer` do not crash. The cause is not established; it could be an overflow flag left by the first constant, which the second one's call then trips over. - -Severity: a compiler crash, and the project cannot be opened for compiling until the constant is removed. - - - ---- - -## A module-level array `Const` initialised from an element of another array `Const` crashes the compiler - -**Describe the bug** -At module level, an array `Const` whose `Array(...)` list names an element of another array `Const` crashes the compiler: `Const A() As Long = Array(1, 2, 3)` followed by `Const B() As Long = Array(A(0))`. The compiler crashes again each time the IDE restarts it, so the project cannot be compiled or run. Observed with `tbbuild`, which reports "the compiler crashed 3x". - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `array-const-in-array-const-crash.twinproj` (attached as `array-const-in-array-const-crash.zip`). Its one module is: - ``` - Module Startup - Const A() As Long = Array(1, 2, 3) - Const B() As Long = Array(A(0)) - Public Sub Main() - End Sub - End Module - ``` -2. Compile it. The compiler crashes. - -**Expected behavior** -Either `B` is `Array(1)`, as it is when the two constants are declared in a procedure, or the compiler refuses the element with an error, as it does for a scalar constant (`Const S As Long = A(0)` is TB5002). - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -BETA 995 has no array constants: every array `Const` is refused there with TB5245. VB6 has none either, so there is no VB6 project. - -It crashes with `B` used or unused, with `Private` constants, with `Double` arrays, with the two in the opposite order, and when an array refers to its own element (`Const B() As Long = Array(1, B(0))`). - -What does not crash: the same two constants inside a procedure compile clean; an array whose element is a scalar `Const` compiles; `Array(A(0) + 1)` is an ordinary TB5001, *Type mismatch*. - -Severity: a compiler crash, and the project cannot be compiled until the constant is removed. - - - ---- - -## `ShiftRotateLeft`, `ShiftRotateRight`, `ShiftUnsignedRight` and `ByteSwap` ignore argument names and take their arguments by position - -**Describe the bug** -The four new intrinsics of the VBA package (BETA 997) take their arguments by position and discard the names a call gives. `ShiftRotateLeft(ShiftAmount:=4, Number:=n)` uses 4 as the number and `n` as the shift amount, with no error, so the result is wrong. A name that matches no parameter, as in `ShiftRotateLeft(Foo:=n, Bar:=k)`, is accepted too. An ordinary procedure honours the names. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `math-intrinsics-ignore-argument-names.twinproj` (attached as `math-intrinsics-ignore-argument-names.zip`). Its `Sub Main` calls each intrinsic with a `Long` `n` of `&H12345678` and `k` of 4, in several forms. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - positional 23456781 - named, in order 23456781 - named, swapped 4000000 - right, named, swapped 400 - unsigned, named, swapped 0 - unknown names 23456781 - ByteSwap, unknown name 78563412 - own function, swapped 23456781 - ``` - -**Expected behavior** -The names select the parameters, as they do for any procedure (the last line, a function of the project with the same parameter names): `ShiftRotateLeft(ShiftAmount:=4, Number:=n)` is `23456781`, `ShiftRotateRight` of the same is `81234567` and `ShiftUnsignedRight` is `01234567`. A name that matches no parameter is a compile error, as it is in VB6 (*Named argument not found*). - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -The four intrinsics are new in BETA 997; BETA 995 does not know them (TB5079). VB6 has no equivalent functions, so there is no VB6 project; the VB6 behaviour quoted is for a named argument that matches no parameter. - -The declared parameters are `Number` and `ShiftAmount` (`ByteSwap` has `Number`). `Compilation.UnrollLoop Bogus:=4` is accepted in the same way. Calls that give the arguments in declaration order, with or without the names, work. - -Severity: a silent wrong result, but only for a call that names its arguments out of declaration order or misspells a name. - - - ---- - -## A module-level array `Const` declared after a procedure is not found by the procedures that follow it - -**Describe the bug** -In a module, an array `Const` that is declared after a procedure cannot be used by the procedures declared after it: the use is TB5079, *Unrecognized symbol*. A scalar `Const` in the same place works. A procedure declared before the constant does see the name, and reads the array as empty: `Arr(1)` raises error 9 at run time. Observed in the compiler's diagnostics. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `array-const-after-procedure-not-found.twinproj` (attached as `array-const-after-procedure-not-found.zip`). Its module is: - ``` - Module Startup - Public Sub Main() - End Sub - Const Scalar As Long = 5 - Const Arr() As Long = Array(1, 2, 3) - Public Sub UseIt() - Debug.Print Scalar - Debug.Print Arr(1) - End Sub - End Module - ``` -2. Compile it. The only error is `TB5079 Unrecognized symbol 'Arr'` on the use in `UseIt`; `Scalar` is found. - -**Expected behavior** -No error: the constant is visible to every procedure of the module wherever it is declared, as a scalar `Const` is. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project. - -Any procedure kind before the constant has the effect: a `Sub`, a `Function` and a `Property Get` were each checked. A declaration before the first procedure is fine, as are one after a `Dim`, an `Enum` or a `Type`. A `Public` array `Const` after a procedure is also unreachable from another module: `Startup.Arr(1)` is TB5027. Qualifying the name inside the module does not help. Classes are not affected: the order does not matter there. - -A procedure declared before the constant does find it, and then fails at run time. With `[RunAfterBuild] Public Sub Run()` first and `Const Arr() As Long = Array(1, 2, 3)` after it, `v = Arr(1)` under `On Error Resume Next` leaves `Err=9 Subscript out of range` and `v` unchanged. - -Severity: low, since the use is refused with an error; the run-time error 9 in an earlier procedure is a less obvious form of the same fault. - - - ---- - -## A `Public` array `Const` in a class is accepted but cannot be reached from outside, where a `Public` scalar `Const` is refused - -**Describe the bug** -A class may not have a `Public` constant: `Public Const Num As Long = 5` in a class is refused with TB5250, *Constants in a class cannot be Public*. For an array constant the declaration `Public Const Arr() As Long = Array(1, 2, 3)` is accepted without a diagnostic, and then `Arr` cannot be reached from outside the class: `obj.Arr(1)` is TB5027, *Unrecognized member*. The declaration is accepted and the constant is private in practice. Observed in the compiler's diagnostics. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `class-public-array-const-unreachable.twinproj` (attached as `class-public-array-const-unreachable.zip`). Class `WithArray` declares `Public Const Arr() As Long = Array(1, 2, 3)` and class `WithScalar` declares `Public Const Num As Long = 5`. `Sub Main` reads `a.Arr(1)` and `s.Num` from instances. -2. Compile it. The diagnostics are: - ``` - Startup.twin [7,21]: TB5027 Unrecognized member 'Arr' on type '_WithArray' [non-extensible object] - Startup.twin [8,21]: TB5027 Unrecognized member 'Num' on type '_WithScalar' [non-extensible object] - WithScalar.twin [3,18]: TB5250 Constants in a class cannot be Public - ``` - The declaration in `WithArray` has no diagnostic of its own. - -**Expected behavior** -The same refusal for both: TB5250 on the declaration of `Arr`, so that the cause is named at the declaration and not as an unrecognized member at each use. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project; a `Public Const` in a VB6 class is refused. - -Inside the class, the array constant reads correctly (`Arr(1)` in `WithArray.Show`). A `Private` array `Const` in a class works, and `Friend`, `Protected` and `Static` are accepted as well. - -Severity: low. The error appears at the use, with a message that does not mention the declaration. - - - ---- - -## Passing a module-level array `Const` to a `ParamArray` compiles clean and then fails with a codegen error - -**Describe the bug** -A module-level array `Const` passed as the argument of a `ParamArray` parameter compiles without a diagnostic, and the build then fails: the linker reports *compilation (codegen) error detected* at the call. The same constant passed to a parameter declared `a() As Long` is refused at compile time with TB5001, and to a `Variant` parameter with TB5077, so the `ParamArray` case slips through the check. Observed with `tbbuild --build`. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `array-const-paramarray-codegen-error.twinproj` (attached as `array-const-paramarray-codegen-error.zip`). Its module: - ``` - Module Startup - Const Values() As Long = Array(10, 20, 30) - Public Sub Main() - Count Values - End Sub - Private Sub Count(ParamArray Items() As Variant) - Debug.Print UBound(Items) - End Sub - End Module - ``` -2. Compile it: no errors. Build it (or press F5). -3. The build log reads `[LINKER] compilation (codegen) error detected in 'Startup.Mainrootmain' at line #7` and `[BUILD] failed`. - -**Expected behavior** -A compile error at the call, as for the other whole-array uses of a constant array (TB5001 or TB5077). Documentation says the array "cannot be assigned to an array variable or a Variant, or passed as an array argument". - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project. - -With LLVM the same call gives *a feature used in your code is not yet supported with the LLVM compiler*. - -What does not reproduce it: the constant passed after another argument (`Count 1, Values`) fails the same way, but `Count Values(0)`, an element, builds clean, an ordinary array variable passed to a `ParamArray` builds clean, and a constant array declared inside the procedure builds clean. - -Severity: low. The build fails with a message that gives the line, but not the cause. - - - ---- - -## LLVM-compiled code rounds a `Single` or `Double` to an integer half away from zero, where VB6 and plain code round half to even - -**Describe the bug** -In a procedure compiled with LLVM, `CLng`, `CInt` and `CLngLng` of a `Single` or `Double` variable, an assignment of one to a `Long` or `Integer`, and the operand conversion of `\` round a value exactly halfway between two integers away from zero: `CLng(2.5)` of a `Double` variable is 3 and `CLng(-0.5)` is -1. The same code without LLVM rounds to the even integer, 2 and 0, as VB6 does. A value that is not a tie rounds the same everywhere. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `llvm-conversion-rounds-half-away.twinproj` (attached as `llvm-conversion-rounds-half-away.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its `[CompilerOptions("+llvm")]`, and `PlainCases` holds the same statements without it. Each procedure converts 0.5, 2.5, 3.5, -0.5 and -2.5. -2. Run it (F5). The DEBUG CONSOLE has one line per value and mode; for 2.5 and -0.5 they read: - ``` - plain 2.5: CLng(d)=2 CLng(s)=2 CInt(d)=2 CLngLng(d)=2 l=d:2 d\1=2 CLng(v)=2 Round=2 - plain -0.5: CLng(d)=0 CLng(s)=0 CInt(d)=0 CLngLng(d)=0 l=d:0 d\1=0 CLng(v)=0 Round=0 - LLVM 2.5: CLng(d)=3 CLng(s)=3 CInt(d)=3 CLngLng(d)=3 l=d:3 d\1=3 CLng(v)=2 Round=2 - LLVM -0.5: CLng(d)=-1 CLng(s)=-1 CInt(d)=-1 CLngLng(d)=-1 l=d:-1 d\1=-1 CLng(v)=0 Round=0 - ``` - -**Expected behavior** -The same result in both: round half to even, as in VB6 (attached as `llvm-conversion-rounds-half-away-vb6.zip`), which prints for the same values `CLng(d)=2` for 2.5 and `CLng(d)=0` for -0.5, `l=d:2`, `d\1=2`, `CLng(v)=2` and `Round=2`, `0` and `0`. VB6 has no `LongLong`, so that column is missing from its output. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically, and on win64. - -What does not differ in LLVM-compiled code: `CLng` of a `Variant` holding the `Double`, `Round`, `Int` and `Fix`, and conversions of a literal such as `CLng(2.5)` (folded to 2 by the compiler). - -A consequence: a value that rounds to zero in plain code can raise an error under LLVM. In a `Byte` conversion, `CByte(-0.5)` and `b = -0.5` (a `Double` variable) give 0 without LLVM and error 6, *Overflow*, with it, because the value becomes -1. - -Severity: a silent wrong result, at ties only, unless the rounding makes a conversion overflow. - - - ---- - -## In LLVM-compiled code a shift count is cut to the width of the shifted `Byte` or `Integer` - -**Describe the bug** -In a procedure compiled with LLVM, the count of `<<` or `>>` is reduced to the width of the shifted type before the shift, for a `Byte` and an `Integer`: `b << 256` with `b` a `Byte` of `&H81` leaves `&H81`, and `b >> 257` gives `&H40`, because the count becomes 0 and 1. Without LLVM both give 0, as the documentation says: a shift by as many bits as the type holds, or more, yields 0. An `Integer` shifted by 65536 or more behaves the same way. A `Long` is not affected, as the count is itself a `Long`. Observed in a run of the reproducer project. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `llvm-shift-count-truncated.twinproj` (attached as `llvm-shift-count-truncated.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its `[CompilerOptions("+llvm")]`, and `PlainCases` holds the same statements without it. -2. Run it (F5) and read the DEBUG CONSOLE: - ``` - plain Byte << 256 0 - plain Byte >> 257 0 - plain Integer << 65536 0 - plain Integer >> 65537 0 - plain Long << 65536 0 - plain Long << -1 80000000 - plain Long >> -1 FFFFFFFF - LLVM Byte << 256 81 - LLVM Byte >> 257 40 - LLVM Integer << 65536 8001 - LLVM Integer >> 65537 C000 - LLVM Long << 65536 0 - LLVM Long << -1 0 - LLVM Long >> -1 0 - ``` - -**Expected behavior** -0 for a `Byte` shifted by 8 or more, and for an `Integer` shifted by 16 or more, whatever the count, in LLVM-compiled code as without LLVM (docs/Reference/Core/LeftShift.md and RightShift.md: "A shift by as many bits as the type holds, or more, yields 0"). VB6 has no shift operators, so there is no VB6 project. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 997 - -**Additional context** -Also on BETA 995, identically, and on win64. - -Counts from 8 to 255 for a `Byte`, and 16 to 65535 for an `Integer`, give 0 in both modes. The difference starts where the count, taken modulo 256 or 65536, is below the width. - -A negative count also differs between the two modes, and the documentation calls the result of a negative count "no useful result". Without LLVM the count is reduced modulo 32 for every type (`1& << -1` is `&H80000000`, `&H80000001 >> -1` is `&HFFFFFFFF`); with LLVM a negative count gives 0 (a `Long`, `LongLong` or `Integer`; a `Byte` gives 0 too). Neither is documented, so that part is a difference, not a departure. - -Severity: a silent wrong result for a `Byte` shifted by 256 or more, or an `Integer` by 65536 or more. Counts that large are rare. - - - - - diff --git a/bugs/array-const-after-procedure-not-found/repro.json b/bugs/array-const-after-procedure-not-found/repro.json deleted file mode 100644 index 92a3ac258..000000000 --- a/bugs/array-const-after-procedure-not-found/repro.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mode": "compile", - "expect": { - "exit": 1, - "diagnostics": ["TB5079"] - } -} diff --git a/bugs/class-public-array-const-unreachable/repro.json b/bugs/class-public-array-const-unreachable/repro.json deleted file mode 100644 index c665aa318..000000000 --- a/bugs/class-public-array-const-unreachable/repro.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mode": "compile", - "expect": { - "exit": 1, - "diagnostics": ["TB5027", "TB5250"] - } -} diff --git a/bugs/filed/advise-nothing-crashes/REPORT.md b/bugs/filed/advise-nothing-crashes/REPORT.md new file mode 100644 index 000000000..100249983 --- /dev/null +++ b/bugs/filed/advise-nothing-crashes/REPORT.md @@ -0,0 +1,44 @@ +Filed as [twinbasic/twinbasic#2488](https://github.com/twinbasic/twinbasic/issues/2488). + +## `IConnectionPoint.Advise(Nothing)` on a twinBASIC class's connection point ends in an access violation + +**Describe the bug** +A class that declares an `Event` is a connectable object, and its connection point is reached through `IConnectionPointContainer`. Calling `Advise` on that connection point with a null sink raises a native `ACCESS_VIOLATION` and ends the run, instead of returning an error. It is the same crash whether the argument is `Nothing` or an unassigned `stdole.IUnknown` variable. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `advise-nothing-crashes.twinproj` (attached as `advise-nothing-crashes.zip`). Its one source file, `Startup.twin`, declares the project's own copies of `IConnectionPointContainer`, `IEnumConnectionPoints` and `IConnectionPoint` (`stdole` has none of them), a class with one event, and this: + ``` + Private Class Source + Public Event Ping() + End Class + + Public Sub Main() + Dim src As New Source + Dim container As IConnectionPointContainer = src + Dim points As IEnumConnectionPoints = container.EnumConnectionPoints() + Dim point As IConnectionPoint, fetched As Long + points.Next 1, point, fetched + Debug.Print "before" + Dim cookie As Long = point.Advise(Nothing) + Debug.Print "after " & cookie + End Sub + ``` +2. Run the project in the IDE (F5). +3. See `before` in the DEBUG CONSOLE, and then `NATIVE EXCEPTION: ACCESS_VIOLATION /Startup.twin; Startup.Main LINE 000033 [...twinBASIC_win32.dll+00389F3E]`. `after` is never printed. + +**Expected behavior** +`Advise` returns `E_POINTER`, which twinBASIC raises as run-time error `&H80004003` that `On Error` can handle. The Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) lists `E_POINTER` for "The value in *pUnkSink* or *pdwCookie* is not valid. For example, either pointer may be **NULL**." + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low in practice, since a program rarely advises a null sink, but a COM client written in any language can send one, and the connection point crashes the process that hosts the class instead of refusing the call. + +What was tried: an object that is not a sink gives an ordinary error (`E_NOINTERFACE`, `&H80004002`), so only a null pointer crashes. The address of the crash is the same in the standalone probe and in this project. + +VB6 crashes too. The same call on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-nothing-crashes-vb6.zip`), ends the exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`, in the Application event log) after it writes `before`. A fix therefore costs no compatibility: no program depends on the crash. + + diff --git a/bugs/advise-nothing-crashes/advise-nothing-crashes.twinproj b/bugs/filed/advise-nothing-crashes/advise-nothing-crashes.twinproj similarity index 100% rename from bugs/advise-nothing-crashes/advise-nothing-crashes.twinproj rename to bugs/filed/advise-nothing-crashes/advise-nothing-crashes.twinproj diff --git a/bugs/advise-nothing-crashes/repro.json b/bugs/filed/advise-nothing-crashes/repro.json similarity index 86% rename from bugs/advise-nothing-crashes/repro.json rename to bugs/filed/advise-nothing-crashes/repro.json index 17c671b8e..6d9e3116b 100644 --- a/bugs/advise-nothing-crashes/repro.json +++ b/bugs/filed/advise-nothing-crashes/repro.json @@ -6,5 +6,6 @@ "^before$", "NATIVE EXCEPTION: ACCESS_VIOLATION" ] - } + }, + "issue": 2488 } diff --git a/bugs/advise-nothing-crashes/src/Settings b/bugs/filed/advise-nothing-crashes/src/Settings similarity index 100% rename from bugs/advise-nothing-crashes/src/Settings rename to bugs/filed/advise-nothing-crashes/src/Settings diff --git a/bugs/advise-nothing-crashes/src/Sources/Startup.twin b/bugs/filed/advise-nothing-crashes/src/Sources/Startup.twin similarity index 100% rename from bugs/advise-nothing-crashes/src/Sources/Startup.twin rename to bugs/filed/advise-nothing-crashes/src/Sources/Startup.twin diff --git a/bugs/advise-nothing-crashes/vb6/Module1.bas b/bugs/filed/advise-nothing-crashes/vb6/Module1.bas similarity index 100% rename from bugs/advise-nothing-crashes/vb6/Module1.bas rename to bugs/filed/advise-nothing-crashes/vb6/Module1.bas diff --git a/bugs/advise-nothing-crashes/vb6/Probe.vbp b/bugs/filed/advise-nothing-crashes/vb6/Probe.vbp similarity index 100% rename from bugs/advise-nothing-crashes/vb6/Probe.vbp rename to bugs/filed/advise-nothing-crashes/vb6/Probe.vbp diff --git a/bugs/advise-nothing-crashes/vb6/Source.cls b/bugs/filed/advise-nothing-crashes/vb6/Source.cls similarity index 100% rename from bugs/advise-nothing-crashes/vb6/Source.cls rename to bugs/filed/advise-nothing-crashes/vb6/Source.cls diff --git a/bugs/filed/advise-unadvise-hresults/REPORT.md b/bugs/filed/advise-unadvise-hresults/REPORT.md new file mode 100644 index 000000000..22e07b371 --- /dev/null +++ b/bugs/filed/advise-unadvise-hresults/REPORT.md @@ -0,0 +1,44 @@ +Filed as [twinbasic/twinbasic#2487](https://github.com/twinbasic/twinbasic/issues/2487). + +## `Unadvise` with a cookie that names no connection succeeds, where VB6 returns CONNECT_E_NOCONNECTION + +**Describe the bug** +On the connection point of a twinBASIC class that has an `Event`, `Unadvise` with a cookie that names no connection (0 and 99 were tried) succeeds silently and does nothing. The COM contract returns an error, and VB6 returns `CONNECT_E_NOCONNECTION` (`&H80040200`). + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `advise-unadvise-hresults.twinproj` (attached as `advise-unadvise-hresults.zip`). Its one source file, `Startup.twin`, declares the project's own copies of the connection-point interfaces (`stdole` has none of them), a class `Source` with one event, and a class `NotASink` with one field. `Sub Main` runs, with `On Error Resume Next`: + ``` + cookie = point.Advise(sink) ' sink is a NotASink + point.Unadvise 0 + point.Unadvise 99 + ``` +2. Run the project in the IDE (F5). +3. See in the DEBUG CONSOLE: + ``` + Advise, a sink without the outgoing interface: error=80004002 + Unadvise 0: error=0 + Unadvise 99: error=0 + ``` + +**Expected behavior** +`Unadvise` reports the bad cookie, as VB6 does. The page for [IConnectionPoint::Unadvise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-unadvise) lists `E_POINTER` for "The value in *dwCookie* does not represent a valid connection", and `CONNECT_E_NOCONNECTION` is the code the `CONNECT_E_` range has for it. It is not `S_OK`. + +The same calls on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-unadvise-hresults-vb6.zip`): +``` +Advise, a sink without the outgoing interface: error=80004002 + cookie=-1 +Unadvise 99: error=80040200 +before Unadvise 0 +``` +`Unadvise 0` comes last because it ends the VB6 exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`). twinBASIC's 0 for that cookie is no worse than VB6's crash; the cookie 99 is the clean comparison. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low. A client that releases a connection twice, or with a wrong cookie, is told it worked. + +`Advise` with a sink that does not answer `QueryInterface` for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) says the method "must return CONNECT_E_CANNOTCONNECT" (`&H80040201`). VB6 returns `E_NOINTERFACE` too, so that is not reported here; the reproducer prints it as a control. A sink that is an ordinary twinBASIC class gives the same `E_NOINTERFACE` whether it has no members or implements `IDispatch` (the outgoing interface's identifier is generated for each build, so a twinBASIC class cannot answer for it). Nothing is connected afterwards, and no event reaches the sink. Calling `Unadvise` a second time with the cookie of the failed `Advise`, which is 0, also returns without an error. + diff --git a/bugs/advise-unadvise-hresults/advise-unadvise-hresults.twinproj b/bugs/filed/advise-unadvise-hresults/advise-unadvise-hresults.twinproj similarity index 100% rename from bugs/advise-unadvise-hresults/advise-unadvise-hresults.twinproj rename to bugs/filed/advise-unadvise-hresults/advise-unadvise-hresults.twinproj diff --git a/bugs/advise-unadvise-hresults/repro.json b/bugs/filed/advise-unadvise-hresults/repro.json similarity index 90% rename from bugs/advise-unadvise-hresults/repro.json rename to bugs/filed/advise-unadvise-hresults/repro.json index 3eb8edb38..a7a46e940 100644 --- a/bugs/advise-unadvise-hresults/repro.json +++ b/bugs/filed/advise-unadvise-hresults/repro.json @@ -7,5 +7,6 @@ "^Unadvise 0: error=0$", "^Unadvise 99: error=0$" ] - } + }, + "issue": 2487 } diff --git a/bugs/advise-unadvise-hresults/src/Settings b/bugs/filed/advise-unadvise-hresults/src/Settings similarity index 100% rename from bugs/advise-unadvise-hresults/src/Settings rename to bugs/filed/advise-unadvise-hresults/src/Settings diff --git a/bugs/advise-unadvise-hresults/src/Sources/Startup.twin b/bugs/filed/advise-unadvise-hresults/src/Sources/Startup.twin similarity index 100% rename from bugs/advise-unadvise-hresults/src/Sources/Startup.twin rename to bugs/filed/advise-unadvise-hresults/src/Sources/Startup.twin diff --git a/bugs/advise-unadvise-hresults/vb6/Module1.bas b/bugs/filed/advise-unadvise-hresults/vb6/Module1.bas similarity index 100% rename from bugs/advise-unadvise-hresults/vb6/Module1.bas rename to bugs/filed/advise-unadvise-hresults/vb6/Module1.bas diff --git a/bugs/advise-unadvise-hresults/vb6/NotASink.cls b/bugs/filed/advise-unadvise-hresults/vb6/NotASink.cls similarity index 100% rename from bugs/advise-unadvise-hresults/vb6/NotASink.cls rename to bugs/filed/advise-unadvise-hresults/vb6/NotASink.cls diff --git a/bugs/advise-unadvise-hresults/vb6/Probe.vbp b/bugs/filed/advise-unadvise-hresults/vb6/Probe.vbp similarity index 100% rename from bugs/advise-unadvise-hresults/vb6/Probe.vbp rename to bugs/filed/advise-unadvise-hresults/vb6/Probe.vbp diff --git a/bugs/advise-unadvise-hresults/vb6/Source.cls b/bugs/filed/advise-unadvise-hresults/vb6/Source.cls similarity index 100% rename from bugs/advise-unadvise-hresults/vb6/Source.cls rename to bugs/filed/advise-unadvise-hresults/vb6/Source.cls diff --git a/bugs/filed/array-const-after-procedure-not-found/REPORT.md b/bugs/filed/array-const-after-procedure-not-found/REPORT.md new file mode 100644 index 000000000..46acf8cad --- /dev/null +++ b/bugs/filed/array-const-after-procedure-not-found/REPORT.md @@ -0,0 +1,41 @@ +Filed as [twinbasic/twinbasic#2472](https://github.com/twinbasic/twinbasic/issues/2472). + +## A module-level array `Const` declared after a procedure is not found by the procedures that follow it + +**Describe the bug** +In a module, an array `Const` that is declared after a procedure cannot be used by the procedures declared after it: the use is TB5079, *Unrecognized symbol*. A scalar `Const` in the same place works. A procedure declared before the constant does see the name, and reads the array as empty: `Arr(1)` raises error 9 at run time. Observed in the compiler's diagnostics. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `array-const-after-procedure-not-found.twinproj` (attached as `array-const-after-procedure-not-found.zip`). Its module is: + ``` + Module Startup + Public Sub Main() + End Sub + Const Scalar As Long = 5 + Const Arr() As Long = Array(1, 2, 3) + Public Sub UseIt() + Debug.Print Scalar + Debug.Print Arr(1) + End Sub + End Module + ``` +2. Compile it. The only error is `TB5079 Unrecognized symbol 'Arr'` on the use in `UseIt`; `Scalar` is found. + +**Expected behavior** +No error: the constant is visible to every procedure of the module wherever it is declared, as a scalar `Const` is. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project. + +Any procedure kind before the constant has the effect: a `Sub`, a `Function` and a `Property Get` were each checked. A declaration before the first procedure is fine, as are one after a `Dim`, an `Enum` or a `Type`. A `Public` array `Const` after a procedure is also unreachable from another module: `Startup.Arr(1)` is TB5027. Qualifying the name inside the module does not help. Classes are not affected: the order does not matter there. + +A procedure declared before the constant does find it, and then fails at run time. With `[RunAfterBuild] Public Sub Run()` first and `Const Arr() As Long = Array(1, 2, 3)` after it, `v = Arr(1)` under `On Error Resume Next` leaves `Err=9 Subscript out of range` and `v` unchanged. + +Severity: low, since the use is refused with an error; the run-time error 9 in an earlier procedure is a less obvious form of the same fault. + + diff --git a/bugs/array-const-after-procedure-not-found/array-const-after-procedure-not-found.twinproj b/bugs/filed/array-const-after-procedure-not-found/array-const-after-procedure-not-found.twinproj similarity index 100% rename from bugs/array-const-after-procedure-not-found/array-const-after-procedure-not-found.twinproj rename to bugs/filed/array-const-after-procedure-not-found/array-const-after-procedure-not-found.twinproj diff --git a/bugs/filed/array-const-after-procedure-not-found/repro.json b/bugs/filed/array-const-after-procedure-not-found/repro.json new file mode 100644 index 000000000..cf77d60de --- /dev/null +++ b/bugs/filed/array-const-after-procedure-not-found/repro.json @@ -0,0 +1,10 @@ +{ + "mode": "compile", + "expect": { + "exit": 1, + "diagnostics": [ + "TB5079" + ] + }, + "issue": 2472 +} diff --git a/bugs/array-const-after-procedure-not-found/src/Settings b/bugs/filed/array-const-after-procedure-not-found/src/Settings similarity index 100% rename from bugs/array-const-after-procedure-not-found/src/Settings rename to bugs/filed/array-const-after-procedure-not-found/src/Settings diff --git a/bugs/array-const-after-procedure-not-found/src/Sources/Startup.twin b/bugs/filed/array-const-after-procedure-not-found/src/Sources/Startup.twin similarity index 100% rename from bugs/array-const-after-procedure-not-found/src/Sources/Startup.twin rename to bugs/filed/array-const-after-procedure-not-found/src/Sources/Startup.twin diff --git a/bugs/filed/array-const-element-overflow-crash/REPORT.md b/bugs/filed/array-const-element-overflow-crash/REPORT.md new file mode 100644 index 000000000..27a02d03b --- /dev/null +++ b/bugs/filed/array-const-element-overflow-crash/REPORT.md @@ -0,0 +1,36 @@ +Filed as [twinbasic/twinbasic#2475](https://github.com/twinbasic/twinbasic/issues/2475). + +## A module-level array `Const` with an element that does not fit its type crashes the compiler + +**Describe the bug** +An array `Const` at module level whose element is too large for the element type takes the compiler down: `Const Big() As Long = Array(2147483648)`. The IDE's compile restarts the compiler, which crashes again, and the project can neither be compiled nor run. The crash happens whether or not the constant is used. Observed with `tbbuild`, which reports "the compiler crashed 3x". + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `array-const-element-overflow-crash.twinproj` (attached as `array-const-element-overflow-crash.zip`). Its one module is: + ``` + Module Startup + Const Big() As Long = Array(2147483648) + Public Sub Main() + End Sub + End Module + ``` +2. Compile it. The compiler crashes. + +**Expected behavior** +A compile error, as for a scalar constant: `Const K As Long = 2147483648` is refused with TB5002, and `Const Small() As Integer = Array(32768)` with TB5001. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +BETA 995 has no array constants: every array `Const` is refused there with TB5245, *array cannot be initialized here*. VB6 has no array constants either (`Const A() As Long = Array(1)` is a syntax error), so there is no VB6 project. + +What crashes, each in a module of its own, unused or used: `Array(2147483648)`, `Array(2147483648#)`, `Array(3000000000.5)` and `Array(1, 2147483648)` for `Long`; `Array(40000.5)` for `Integer`; `Array(300.5)` and `Array(-1.5)` for `Byte`; `Array(9223372036854775808#)` for `LongLong`. What does not: the same constant declared inside a procedure compiles clean; a `Single` or `Double` array with a huge element (`Array(1E+300)`) compiles; an `Integer` or `Byte` element that is an integer literal too large (`Array(32768)`) is the ordinary TB5001. + +A second crash seems to belong with this one, and it is not in the reproducer. A `Single` array `Const` whose element overflows, `Const A() As Single = Array(1E+39)`, compiles alone, but crashes the compiler when a later array `Const` of `Double` calls `Sqr`, `Timer` or `Rnd`, for example `Const B() As Double = Array(Sqr(4))` on the next line. The reverse order, `Abs` or `Len` in place of those, `Now` or `Date`, and a `Single` array of `Timer` do not crash. The cause is not established; it could be an overflow flag left by the first constant, which the second one's call then trips over. + +Severity: a compiler crash, and the project cannot be opened for compiling until the constant is removed. + + diff --git a/bugs/array-const-element-overflow-crash/array-const-element-overflow-crash.twinproj b/bugs/filed/array-const-element-overflow-crash/array-const-element-overflow-crash.twinproj similarity index 100% rename from bugs/array-const-element-overflow-crash/array-const-element-overflow-crash.twinproj rename to bugs/filed/array-const-element-overflow-crash/array-const-element-overflow-crash.twinproj diff --git a/bugs/array-const-in-array-const-crash/repro.json b/bugs/filed/array-const-element-overflow-crash/repro.json similarity index 71% rename from bugs/array-const-in-array-const-crash/repro.json rename to bugs/filed/array-const-element-overflow-crash/repro.json index db90b39cf..5be4e77bf 100644 --- a/bugs/array-const-in-array-const-crash/repro.json +++ b/bugs/filed/array-const-element-overflow-crash/repro.json @@ -2,5 +2,6 @@ "mode": "compile", "expect": { "exit": 4 - } + }, + "issue": 2475 } diff --git a/bugs/array-const-element-overflow-crash/src/Settings b/bugs/filed/array-const-element-overflow-crash/src/Settings similarity index 100% rename from bugs/array-const-element-overflow-crash/src/Settings rename to bugs/filed/array-const-element-overflow-crash/src/Settings diff --git a/bugs/array-const-element-overflow-crash/src/Sources/Startup.twin b/bugs/filed/array-const-element-overflow-crash/src/Sources/Startup.twin similarity index 100% rename from bugs/array-const-element-overflow-crash/src/Sources/Startup.twin rename to bugs/filed/array-const-element-overflow-crash/src/Sources/Startup.twin diff --git a/bugs/filed/array-const-in-array-const-crash/REPORT.md b/bugs/filed/array-const-in-array-const-crash/REPORT.md new file mode 100644 index 000000000..1a2ba6f71 --- /dev/null +++ b/bugs/filed/array-const-in-array-const-crash/REPORT.md @@ -0,0 +1,37 @@ +Filed as [twinbasic/twinbasic#2474](https://github.com/twinbasic/twinbasic/issues/2474). + +## A module-level array `Const` initialised from an element of another array `Const` crashes the compiler + +**Describe the bug** +At module level, an array `Const` whose `Array(...)` list names an element of another array `Const` crashes the compiler: `Const A() As Long = Array(1, 2, 3)` followed by `Const B() As Long = Array(A(0))`. The compiler crashes again each time the IDE restarts it, so the project cannot be compiled or run. Observed with `tbbuild`, which reports "the compiler crashed 3x". + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `array-const-in-array-const-crash.twinproj` (attached as `array-const-in-array-const-crash.zip`). Its one module is: + ``` + Module Startup + Const A() As Long = Array(1, 2, 3) + Const B() As Long = Array(A(0)) + Public Sub Main() + End Sub + End Module + ``` +2. Compile it. The compiler crashes. + +**Expected behavior** +Either `B` is `Array(1)`, as it is when the two constants are declared in a procedure, or the compiler refuses the element with an error, as it does for a scalar constant (`Const S As Long = A(0)` is TB5002). + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +BETA 995 has no array constants: every array `Const` is refused there with TB5245. VB6 has none either, so there is no VB6 project. + +It crashes with `B` used or unused, with `Private` constants, with `Double` arrays, with the two in the opposite order, and when an array refers to its own element (`Const B() As Long = Array(1, B(0))`). + +What does not crash: the same two constants inside a procedure compile clean; an array whose element is a scalar `Const` compiles; `Array(A(0) + 1)` is an ordinary TB5001, *Type mismatch*. + +Severity: a compiler crash, and the project cannot be compiled until the constant is removed. + + diff --git a/bugs/array-const-in-array-const-crash/array-const-in-array-const-crash.twinproj b/bugs/filed/array-const-in-array-const-crash/array-const-in-array-const-crash.twinproj similarity index 100% rename from bugs/array-const-in-array-const-crash/array-const-in-array-const-crash.twinproj rename to bugs/filed/array-const-in-array-const-crash/array-const-in-array-const-crash.twinproj diff --git a/bugs/array-const-element-overflow-crash/repro.json b/bugs/filed/array-const-in-array-const-crash/repro.json similarity index 71% rename from bugs/array-const-element-overflow-crash/repro.json rename to bugs/filed/array-const-in-array-const-crash/repro.json index db90b39cf..b9926dae5 100644 --- a/bugs/array-const-element-overflow-crash/repro.json +++ b/bugs/filed/array-const-in-array-const-crash/repro.json @@ -2,5 +2,6 @@ "mode": "compile", "expect": { "exit": 4 - } + }, + "issue": 2474 } diff --git a/bugs/array-const-in-array-const-crash/src/Settings b/bugs/filed/array-const-in-array-const-crash/src/Settings similarity index 100% rename from bugs/array-const-in-array-const-crash/src/Settings rename to bugs/filed/array-const-in-array-const-crash/src/Settings diff --git a/bugs/array-const-in-array-const-crash/src/Sources/Startup.twin b/bugs/filed/array-const-in-array-const-crash/src/Sources/Startup.twin similarity index 100% rename from bugs/array-const-in-array-const-crash/src/Sources/Startup.twin rename to bugs/filed/array-const-in-array-const-crash/src/Sources/Startup.twin diff --git a/bugs/filed/array-const-paramarray-codegen-error/REPORT.md b/bugs/filed/array-const-paramarray-codegen-error/REPORT.md new file mode 100644 index 000000000..56f4ed276 --- /dev/null +++ b/bugs/filed/array-const-paramarray-codegen-error/REPORT.md @@ -0,0 +1,41 @@ +Filed as [twinbasic/twinbasic#2470](https://github.com/twinbasic/twinbasic/issues/2470). + +## Passing a module-level array `Const` to a `ParamArray` compiles clean and then fails with a codegen error + +**Describe the bug** +A module-level array `Const` passed as the argument of a `ParamArray` parameter compiles without a diagnostic, and the build then fails: the linker reports *compilation (codegen) error detected* at the call. The same constant passed to a parameter declared `a() As Long` is refused at compile time with TB5001, and to a `Variant` parameter with TB5077, so the `ParamArray` case slips through the check. Observed with `tbbuild --build`. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `array-const-paramarray-codegen-error.twinproj` (attached as `array-const-paramarray-codegen-error.zip`). Its module: + ``` + Module Startup + Const Values() As Long = Array(10, 20, 30) + Public Sub Main() + Count Values + End Sub + Private Sub Count(ParamArray Items() As Variant) + Debug.Print UBound(Items) + End Sub + End Module + ``` +2. Compile it: no errors. Build it (or press F5). +3. The build log reads `[LINKER] compilation (codegen) error detected in 'Startup.Mainrootmain' at line #7` and `[BUILD] failed`. + +**Expected behavior** +A compile error at the call, as for the other whole-array uses of a constant array (TB5001 or TB5077). Documentation says the array "cannot be assigned to an array variable or a Variant, or passed as an array argument". + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project. + +With LLVM the same call gives *a feature used in your code is not yet supported with the LLVM compiler*. + +What does not reproduce it: the constant passed after another argument (`Count 1, Values`) fails the same way, but `Count Values(0)`, an element, builds clean, an ordinary array variable passed to a `ParamArray` builds clean, and a constant array declared inside the procedure builds clean. + +Severity: low. The build fails with a message that gives the line, but not the cause. + + diff --git a/bugs/array-const-paramarray-codegen-error/array-const-paramarray-codegen-error.twinproj b/bugs/filed/array-const-paramarray-codegen-error/array-const-paramarray-codegen-error.twinproj similarity index 100% rename from bugs/array-const-paramarray-codegen-error/array-const-paramarray-codegen-error.twinproj rename to bugs/filed/array-const-paramarray-codegen-error/array-const-paramarray-codegen-error.twinproj diff --git a/bugs/array-const-paramarray-codegen-error/repro.json b/bugs/filed/array-const-paramarray-codegen-error/repro.json similarity index 78% rename from bugs/array-const-paramarray-codegen-error/repro.json rename to bugs/filed/array-const-paramarray-codegen-error/repro.json index d43c1f71d..4946aa4be 100644 --- a/bugs/array-const-paramarray-codegen-error/repro.json +++ b/bugs/filed/array-const-paramarray-codegen-error/repro.json @@ -3,5 +3,6 @@ "expect": { "exit": 5, "message": "codegen" - } + }, + "issue": 2470 } diff --git a/bugs/array-const-paramarray-codegen-error/src/Settings b/bugs/filed/array-const-paramarray-codegen-error/src/Settings similarity index 100% rename from bugs/array-const-paramarray-codegen-error/src/Settings rename to bugs/filed/array-const-paramarray-codegen-error/src/Settings diff --git a/bugs/array-const-paramarray-codegen-error/src/Sources/Startup.twin b/bugs/filed/array-const-paramarray-codegen-error/src/Sources/Startup.twin similarity index 100% rename from bugs/array-const-paramarray-codegen-error/src/Sources/Startup.twin rename to bugs/filed/array-const-paramarray-codegen-error/src/Sources/Startup.twin diff --git a/bugs/filed/class-public-array-const-unreachable/REPORT.md b/bugs/filed/class-public-array-const-unreachable/REPORT.md new file mode 100644 index 000000000..16ef603db --- /dev/null +++ b/bugs/filed/class-public-array-const-unreachable/REPORT.md @@ -0,0 +1,33 @@ +Filed as [twinbasic/twinbasic#2471](https://github.com/twinbasic/twinbasic/issues/2471). + +## A `Public` array `Const` in a class is accepted but cannot be reached from outside, where a `Public` scalar `Const` is refused + +**Describe the bug** +A class may not have a `Public` constant: `Public Const Num As Long = 5` in a class is refused with TB5250, *Constants in a class cannot be Public*. For an array constant the declaration `Public Const Arr() As Long = Array(1, 2, 3)` is accepted without a diagnostic, and then `Arr` cannot be reached from outside the class: `obj.Arr(1)` is TB5027, *Unrecognized member*. The declaration is accepted and the constant is private in practice. Observed in the compiler's diagnostics. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `class-public-array-const-unreachable.twinproj` (attached as `class-public-array-const-unreachable.zip`). Class `WithArray` declares `Public Const Arr() As Long = Array(1, 2, 3)` and class `WithScalar` declares `Public Const Num As Long = 5`. `Sub Main` reads `a.Arr(1)` and `s.Num` from instances. +2. Compile it. The diagnostics are: + ``` + Startup.twin [7,21]: TB5027 Unrecognized member 'Arr' on type '_WithArray' [non-extensible object] + Startup.twin [8,21]: TB5027 Unrecognized member 'Num' on type '_WithScalar' [non-extensible object] + WithScalar.twin [3,18]: TB5250 Constants in a class cannot be Public + ``` + The declaration in `WithArray` has no diagnostic of its own. + +**Expected behavior** +The same refusal for both: TB5250 on the declaration of `Arr`, so that the cause is named at the declaration and not as an unrecognized member at each use. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +BETA 995 has no array constants (TB5245). VB6 has none either, so there is no VB6 project; a `Public Const` in a VB6 class is refused. + +Inside the class, the array constant reads correctly (`Arr(1)` in `WithArray.Show`). A `Private` array `Const` in a class works, and `Friend`, `Protected` and `Static` are accepted as well. + +Severity: low. The error appears at the use, with a message that does not mention the declaration. + + diff --git a/bugs/class-public-array-const-unreachable/class-public-array-const-unreachable.twinproj b/bugs/filed/class-public-array-const-unreachable/class-public-array-const-unreachable.twinproj similarity index 100% rename from bugs/class-public-array-const-unreachable/class-public-array-const-unreachable.twinproj rename to bugs/filed/class-public-array-const-unreachable/class-public-array-const-unreachable.twinproj diff --git a/bugs/filed/class-public-array-const-unreachable/repro.json b/bugs/filed/class-public-array-const-unreachable/repro.json new file mode 100644 index 000000000..e45459bdc --- /dev/null +++ b/bugs/filed/class-public-array-const-unreachable/repro.json @@ -0,0 +1,11 @@ +{ + "mode": "compile", + "expect": { + "exit": 1, + "diagnostics": [ + "TB5027", + "TB5250" + ] + }, + "issue": 2471 +} diff --git a/bugs/class-public-array-const-unreachable/src/Settings b/bugs/filed/class-public-array-const-unreachable/src/Settings similarity index 100% rename from bugs/class-public-array-const-unreachable/src/Settings rename to bugs/filed/class-public-array-const-unreachable/src/Settings diff --git a/bugs/class-public-array-const-unreachable/src/Sources/Startup.twin b/bugs/filed/class-public-array-const-unreachable/src/Sources/Startup.twin similarity index 100% rename from bugs/class-public-array-const-unreachable/src/Sources/Startup.twin rename to bugs/filed/class-public-array-const-unreachable/src/Sources/Startup.twin diff --git a/bugs/class-public-array-const-unreachable/src/Sources/WithArray.twin b/bugs/filed/class-public-array-const-unreachable/src/Sources/WithArray.twin similarity index 100% rename from bugs/class-public-array-const-unreachable/src/Sources/WithArray.twin rename to bugs/filed/class-public-array-const-unreachable/src/Sources/WithArray.twin diff --git a/bugs/class-public-array-const-unreachable/src/Sources/WithScalar.twin b/bugs/filed/class-public-array-const-unreachable/src/Sources/WithScalar.twin similarity index 100% rename from bugs/class-public-array-const-unreachable/src/Sources/WithScalar.twin rename to bugs/filed/class-public-array-const-unreachable/src/Sources/WithScalar.twin diff --git a/bugs/filed/forecolor-invalid-value-stored/REPORT.md b/bugs/filed/forecolor-invalid-value-stored/REPORT.md new file mode 100644 index 000000000..8c8787e7a --- /dev/null +++ b/bugs/filed/forecolor-invalid-value-stored/REPORT.md @@ -0,0 +1,72 @@ +Filed as [twinbasic/twinbasic#2484](https://github.com/twinbasic/twinbasic/issues/2484). + +## Setting a colour property to a value that is not a colour stores it, and raises error 5 or nothing, where VB6 raises error 380 and keeps the old value + +**Describe the bug** +Assigning a value that is not a colour to a colour property does not leave the property as it was, on a form, on a PictureBox or UserControl, on the Printer, and on every control tried. `Form.ForeColor = -1` and `Form.ForeColor = &H8000001F` raise error 5 (*Invalid procedure call or argument*) and still store the value, so `ForeColor` reads back `FFFFFFFF` and `8000001F` afterwards. `Form.BackColor = -1` and `Form.FillColor = -1` raise nothing and store the value. `ForeColor` raises error 5 on the surfaces that draw with a device context (Form, PictureBox, UserControl, Printer); every other colour property tried raises nothing, for `-1`, `&H8000001F` and `&HFF0000FF` alike. VB6 raises error 380 (*Invalid property value*) for every one of these assignments and keeps the colour the property had. Observed by assigning with `On Error Resume Next` and printing `Err.Number` and the value read back. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `forecolor-invalid-value-stored.twinproj` (attached as `forecolor-invalid-value-stored.zip`). Its `Sub Main` loads `Form1`, which holds a PictureBox, a Label, a TextBox, a CommandButton and a user control, UC1, whose `RunCases` assigns its own colour properties; it then loads `MyMDIForm`, an MDIForm, assigns the same values to its `BackColor`, shows it and assigns them again. For each property tried it sets `vbGreen`, then assigns `-1` (and `&H8000001F` to the form's `ForeColor`) under `On Error Resume Next` and prints the error number and the value read back. The Printer lines need a default printer; nothing is printed. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + Form.ForeColor = -1: Err 5, reads FFFFFFFF + Form.ForeColor = &H8000001F: Err 5, reads 8000001F + Form.BackColor = -1: Err 0, reads FFFFFFFF + Form.FillColor = -1: Err 0, reads FFFFFFFF + PictureBox.ForeColor = -1: Err 5, reads FFFFFFFF + PictureBox.BackColor = -1: Err 0, reads FFFFFFFF + PictureBox.FillColor = -1: Err 0, reads FFFFFFFF + Label.ForeColor = -1: Err 0, reads FFFFFFFF + Label.BackColor = -1: Err 0, reads FFFFFFFF + TextBox.ForeColor = -1: Err 0, reads FFFFFFFF + TextBox.BackColor = -1: Err 0, reads FFFFFFFF + CommandButton.BackColor = -1: Err 0, reads FFFFFFFF + Printer.ForeColor = -1: Err 5, reads FFFFFFFF + Printer.FillColor = -1: Err 0, reads FFFFFFFF + UserControl.ForeColor = -1: Err 5, reads FFFFFFFF + UserControl.BackColor = -1: Err 0, reads FFFFFFFF + UserControl.FillColor = -1: Err 0, reads FFFFFFFF + MDIForm.BackColor = -1: Err 0, reads FFFFFFFF + MDIForm.BackColor = &H8000001F: Err 0, reads 8000001F + MDIForm (shown).BackColor = -1: Err 5, reads FFFFFFFF + MDIForm (shown).BackColor = &H8000001F: Err 5, reads 8000001F + ``` + +**Expected behavior** +Error 380 for each assignment, and the old value kept, as VB6 does (the VB6 project, attached as `forecolor-invalid-value-stored-vb6.zip`, prints): +``` +Form.ForeColor = -1: Err 380, reads FF00 +Form.ForeColor = &H8000001F: Err 380, reads FF00 +Form.BackColor = -1: Err 380, reads FF00 +Form.FillColor = -1: Err 380, reads FF00 +PictureBox.ForeColor = -1: Err 380, reads FF00 +PictureBox.BackColor = -1: Err 380, reads FF00 +PictureBox.FillColor = -1: Err 380, reads FF00 +Label.ForeColor = -1: Err 380, reads FF00 +Label.BackColor = -1: Err 380, reads FF00 +TextBox.ForeColor = -1: Err 380, reads FF00 +TextBox.BackColor = -1: Err 380, reads FF00 +CommandButton.BackColor = -1: Err 380, reads FF00 +Printer.ForeColor = -1: Err 380, reads FF00 +Printer.FillColor = -1: Err 380, reads FF00 +UserControl.ForeColor = -1: Err 380, reads FF00 +UserControl.BackColor = -1: Err 380, reads FF00 +UserControl.FillColor = -1: Err 380, reads FF00 +MDIForm.BackColor = -1: Err 380, reads FF00 +MDIForm.BackColor = &H8000001F: Err 380, reads FF00 +MDIForm (shown).BackColor = -1: Err 380, reads FF00 +MDIForm (shown).BackColor = &H8000001F: Err 380, reads FF00 +``` +`FF00` is `vbGreen`, which each property held before the assignment. A program that sets a colour from user input and handles the error then keeps the colour it had. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low. `ForeColor` raises the error on the surfaces and the other properties do not, and the stored value is what a later `Line`, `Circle`, `PSet` or `Print` then uses (or what the control paints with), so a handled error leaves the invalid colour in force. + +What was tried: the same lines on BETA 983 print the same, so this is not a change of BETA 984. A value that is a valid colour, such as `&H100FF00` or `&H20000FF`, is accepted by `ForeColor` with no error, as in VB6. Besides the attached project, each of `-1`, `&H8000001F` and `&HFF0000FF` was assigned to every colour property of a UserControl (placed on the form), CheckBox, Frame, ListBox, Shape, ComboBox, DirListBox, DriveListBox, FileListBox, OptionButton and OLE in a project of its own, on BETA 995 and 983 and in VB6: the results are the same as above, error 5 for the UserControl's `ForeColor` and none for the rest in twinBASIC, error 380 and the old value kept in VB6. The twinBASIC-only controls behave the same (`QRCode.ForeColor` raises error 5, `CheckMark.BackColor` and `MultiFrame.BackColor` raise nothing), and so do `CommandButton.ForeColor` and the Data control's colours, which VB6 has no counterpart or no loadable project for. The package source explains the pattern: the colour properties are plain stored fields (`Public ForeColor As OLE_COLOR` and so on, in `GraphicsBase` for Form, PictureBox, UserControl, PropertyPage and Report, and declared again in each control), with nothing that validates the value; the error 5 comes from the `ForeColor` change handler of `GraphicsBase`, which runs after the value is stored. + + diff --git a/bugs/forecolor-invalid-value-stored/forecolor-invalid-value-stored.twinproj b/bugs/filed/forecolor-invalid-value-stored/forecolor-invalid-value-stored.twinproj similarity index 100% rename from bugs/forecolor-invalid-value-stored/forecolor-invalid-value-stored.twinproj rename to bugs/filed/forecolor-invalid-value-stored/forecolor-invalid-value-stored.twinproj diff --git a/bugs/forecolor-invalid-value-stored/repro.json b/bugs/filed/forecolor-invalid-value-stored/repro.json similarity index 98% rename from bugs/forecolor-invalid-value-stored/repro.json rename to bugs/filed/forecolor-invalid-value-stored/repro.json index d8ab2da25..5387b988c 100644 --- a/bugs/forecolor-invalid-value-stored/repro.json +++ b/bugs/filed/forecolor-invalid-value-stored/repro.json @@ -25,5 +25,6 @@ "^MDIForm \\(shown\\)\\.BackColor = -1: Err 5, reads FFFFFFFF$", "^MDIForm \\(shown\\)\\.BackColor = &H8000001F: Err 5, reads 8000001F$" ] - } + }, + "issue": 2484 } diff --git a/bugs/forecolor-invalid-value-stored/src/Settings b/bugs/filed/forecolor-invalid-value-stored/src/Settings similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Settings rename to bugs/filed/forecolor-invalid-value-stored/src/Settings diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/Form1.tbform b/bugs/filed/forecolor-invalid-value-stored/src/Sources/Form1.tbform similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/Form1.tbform rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/Form1.tbform diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/Form1.twin b/bugs/filed/forecolor-invalid-value-stored/src/Sources/Form1.twin similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/Form1.twin rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/Form1.twin diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/MyMDIForm.tbform b/bugs/filed/forecolor-invalid-value-stored/src/Sources/MyMDIForm.tbform similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/MyMDIForm.tbform rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/MyMDIForm.tbform diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/MyMDIForm.twin b/bugs/filed/forecolor-invalid-value-stored/src/Sources/MyMDIForm.twin similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/MyMDIForm.twin rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/MyMDIForm.twin diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/Startup.twin b/bugs/filed/forecolor-invalid-value-stored/src/Sources/Startup.twin similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/Startup.twin rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/Startup.twin diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/UC1.tbcontrol b/bugs/filed/forecolor-invalid-value-stored/src/Sources/UC1.tbcontrol similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/UC1.tbcontrol rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/UC1.tbcontrol diff --git a/bugs/forecolor-invalid-value-stored/src/Sources/UC1.twin b/bugs/filed/forecolor-invalid-value-stored/src/Sources/UC1.twin similarity index 100% rename from bugs/forecolor-invalid-value-stored/src/Sources/UC1.twin rename to bugs/filed/forecolor-invalid-value-stored/src/Sources/UC1.twin diff --git a/bugs/forecolor-invalid-value-stored/vb6/Form1.frm b/bugs/filed/forecolor-invalid-value-stored/vb6/Form1.frm similarity index 100% rename from bugs/forecolor-invalid-value-stored/vb6/Form1.frm rename to bugs/filed/forecolor-invalid-value-stored/vb6/Form1.frm diff --git a/bugs/forecolor-invalid-value-stored/vb6/MDIForm1.frm b/bugs/filed/forecolor-invalid-value-stored/vb6/MDIForm1.frm similarity index 100% rename from bugs/forecolor-invalid-value-stored/vb6/MDIForm1.frm rename to bugs/filed/forecolor-invalid-value-stored/vb6/MDIForm1.frm diff --git a/bugs/forecolor-invalid-value-stored/vb6/Module1.bas b/bugs/filed/forecolor-invalid-value-stored/vb6/Module1.bas similarity index 100% rename from bugs/forecolor-invalid-value-stored/vb6/Module1.bas rename to bugs/filed/forecolor-invalid-value-stored/vb6/Module1.bas diff --git a/bugs/forecolor-invalid-value-stored/vb6/Probe.vbp b/bugs/filed/forecolor-invalid-value-stored/vb6/Probe.vbp similarity index 100% rename from bugs/forecolor-invalid-value-stored/vb6/Probe.vbp rename to bugs/filed/forecolor-invalid-value-stored/vb6/Probe.vbp diff --git a/bugs/forecolor-invalid-value-stored/vb6/UC1.ctl b/bugs/filed/forecolor-invalid-value-stored/vb6/UC1.ctl similarity index 100% rename from bugs/forecolor-invalid-value-stored/vb6/UC1.ctl rename to bugs/filed/forecolor-invalid-value-stored/vb6/UC1.ctl diff --git a/bugs/filed/gettypeinfo-badindex/REPORT.md b/bugs/filed/gettypeinfo-badindex/REPORT.md new file mode 100644 index 000000000..8732f3689 --- /dev/null +++ b/bugs/filed/gettypeinfo-badindex/REPORT.md @@ -0,0 +1,40 @@ +Filed as [twinbasic/twinbasic#2489](https://github.com/twinbasic/twinbasic/issues/2489). + +## A twinBASIC class's `GetTypeInfo` with an `iTInfo` of 1 returns E_UNEXPECTED, not DISP_E_BADINDEX + +**Describe the bug** +`IDispatch::GetTypeInfo` on an object of a twinBASIC class returns `E_UNEXPECTED` (`&H8000FFFF`) for any `iTInfo` other than 0. The object's `GetTypeInfoCount` returns 1, so 0 is the only valid index, and the contract gives `DISP_E_BADINDEX` for a bad one. Observed in a run of the reproducer project, calling the method through a project's own declaration of the interface. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `gettypeinfo-badindex.twinproj` (attached as `gettypeinfo-badindex.zip`). Its one source file, `Startup.twin`, declares a copy of `IDispatch` with the same `[InterfaceId]` (the `stdole` declaration cannot be called), a class `Widget` with one `Sub`, and a `Sub Main` that calls `GetTypeInfoCount` and `GetTypeInfo` on a `Widget` through the copy. +2. Run it and read the DEBUG CONSOLE: + ``` + GetTypeInfoCount = 1 + GetTypeInfo(0): HRESULT 0, pointer returned True + GetTypeInfo(1): HRESULT 8000FFFF, error -2147418113, pointer returned False + GetTypeInfo(2): HRESULT 8000FFFF, error -2147418113, pointer returned False + GetTypeInfo(-1): HRESULT 8000FFFF, error -2147418113 + ``` + +**Expected behavior** +`DISP_E_BADINDEX` (`&H8002000B`), which the `IDispatch::GetTypeInfo` documentation gives for an index that is not valid, for an index the object does not have. `E_UNEXPECTED` (*Catastrophic failure*) is the code for a call made at a time when the object cannot take it, and it reads as a fault in the object. + +VB6 does not return `E_UNEXPECTED`. The same calls on a VB6 class, made through `DispCallFunc` (the VB6 project is attached as `gettypeinfo-badindex-vb6.zip`), return `TYPE_E_ELEMENTNOTFOUND` (`&H8002802B`) for every index but 0: +``` +GetTypeInfoCount = 1 (HRESULT 0) +GetTypeInfo(0): HRESULT 0, pointer returned True +GetTypeInfo(1): HRESULT 8002802B, pointer returned False +GetTypeInfo(2): HRESULT 8002802B, pointer returned False +GetTypeInfo(-1): HRESULT 8002802B, pointer returned False +``` +Either `DISP_E_BADINDEX` or VB6's `TYPE_E_ELEMENTNOTFOUND` says that the index names nothing; `E_UNEXPECTED` does not. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: minor. A caller that checks for `DISP_E_BADINDEX` to learn that an object has no more type information gets a different code; `GetTypeInfo(0)` and `GetTypeInfoCount` are right. + + diff --git a/bugs/gettypeinfo-badindex/gettypeinfo-badindex.twinproj b/bugs/filed/gettypeinfo-badindex/gettypeinfo-badindex.twinproj similarity index 100% rename from bugs/gettypeinfo-badindex/gettypeinfo-badindex.twinproj rename to bugs/filed/gettypeinfo-badindex/gettypeinfo-badindex.twinproj diff --git a/bugs/gettypeinfo-badindex/repro.json b/bugs/filed/gettypeinfo-badindex/repro.json similarity index 89% rename from bugs/gettypeinfo-badindex/repro.json rename to bugs/filed/gettypeinfo-badindex/repro.json index 4d56e3f47..5162a076e 100644 --- a/bugs/gettypeinfo-badindex/repro.json +++ b/bugs/filed/gettypeinfo-badindex/repro.json @@ -6,5 +6,6 @@ "^GetTypeInfo\\(0\\): HRESULT 0, pointer returned True$", "^GetTypeInfo\\(1\\): HRESULT 8000FFFF," ] - } + }, + "issue": 2489 } diff --git a/bugs/gettypeinfo-badindex/src/Settings b/bugs/filed/gettypeinfo-badindex/src/Settings similarity index 100% rename from bugs/gettypeinfo-badindex/src/Settings rename to bugs/filed/gettypeinfo-badindex/src/Settings diff --git a/bugs/gettypeinfo-badindex/src/Sources/Startup.twin b/bugs/filed/gettypeinfo-badindex/src/Sources/Startup.twin similarity index 100% rename from bugs/gettypeinfo-badindex/src/Sources/Startup.twin rename to bugs/filed/gettypeinfo-badindex/src/Sources/Startup.twin diff --git a/bugs/gettypeinfo-badindex/vb6/Module1.bas b/bugs/filed/gettypeinfo-badindex/vb6/Module1.bas similarity index 100% rename from bugs/gettypeinfo-badindex/vb6/Module1.bas rename to bugs/filed/gettypeinfo-badindex/vb6/Module1.bas diff --git a/bugs/gettypeinfo-badindex/vb6/Probe.vbp b/bugs/filed/gettypeinfo-badindex/vb6/Probe.vbp similarity index 100% rename from bugs/gettypeinfo-badindex/vb6/Probe.vbp rename to bugs/filed/gettypeinfo-badindex/vb6/Probe.vbp diff --git a/bugs/gettypeinfo-badindex/vb6/Widget.cls b/bugs/filed/gettypeinfo-badindex/vb6/Widget.cls similarity index 100% rename from bugs/gettypeinfo-badindex/vb6/Widget.cls rename to bugs/filed/gettypeinfo-badindex/vb6/Widget.cls diff --git a/bugs/filed/import-no-settings-silent/REPORT.md b/bugs/filed/import-no-settings-silent/REPORT.md new file mode 100644 index 000000000..0dc8d044f --- /dev/null +++ b/bugs/filed/import-no-settings-silent/REPORT.md @@ -0,0 +1,32 @@ +Filed as [twinbasic/twinbasic#2493](https://github.com/twinbasic/twinbasic/issues/2493). + +## `import` of a folder with no `Settings` file fails without saying why + +**Describe the bug** +Given a folder with no `Settings` file at its top, `import` lists the files it read, ends `... FAILED` with no `ERROR:` line, and writes nothing. Every other failure measured names its cause. The refusal is right, and the silence is not. + +**To Reproduce** +Steps to reproduce the behavior: +1. Unzip `import-no-settings-silent.zip` (it holds `import-no-settings-silent.twinproj`, an ordinary project) and export it: `twinBASIC_win32.exe export C:\p\import-no-settings-silent.twinproj C:\p\src\`. +2. Import only the folder `Sources`, which has no `Settings` file at its top: `twinBASIC_win32.exe import C:\p\x.twinproj C:\p\src\Sources\`. +3. See: + ``` + importing into "C:\p\x.twinproj" from "C:\p\src\Sources\"... + IMPORTED: C:\p\src\Sources\\Startup.twin + ... FAILED + ``` +4. See that there is no `ERROR:` line, the exit code is 0, and `C:\p\x.twinproj` was not written. + +**Expected behavior** +An `ERROR:` line that names the cause, such as `ERROR: no Settings file in the input folder`, before `... FAILED`, as the other failures print. A failure should also not exit with 0. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: minor, because the refusal is right. + +Also on BETA 983, where a copy of the HelloWorld sample's tree with `Settings` deleted behaves the same: it lists `Resources\ICON\twinBASIC.ico` and `Sources\HelloWorld.twin`, ends `... FAILED` and writes no project. + + diff --git a/bugs/import-no-settings-silent/import-no-settings-silent.twinproj b/bugs/filed/import-no-settings-silent/import-no-settings-silent.twinproj similarity index 100% rename from bugs/import-no-settings-silent/import-no-settings-silent.twinproj rename to bugs/filed/import-no-settings-silent/import-no-settings-silent.twinproj diff --git a/bugs/filed/import-no-settings-silent/repro.json b/bugs/filed/import-no-settings-silent/repro.json new file mode 100644 index 000000000..ea5367f1d --- /dev/null +++ b/bugs/filed/import-no-settings-silent/repro.json @@ -0,0 +1,19 @@ +{ + "mode": "cli", + "cli": [ + "import", + "{tmp}\\packed.twinproj", + "{src}\\Sources\\" + ], + "expect": { + "exit": 0, + "output": [ + "IMPORTED: .*Startup\\.twin", + "^\\.\\.\\. FAILED" + ], + "absent": [ + "ERROR" + ] + }, + "issue": 2493 +} diff --git a/bugs/import-no-settings-silent/src/Settings b/bugs/filed/import-no-settings-silent/src/Settings similarity index 100% rename from bugs/import-no-settings-silent/src/Settings rename to bugs/filed/import-no-settings-silent/src/Settings diff --git a/bugs/import-no-settings-silent/src/Sources/Startup.twin b/bugs/filed/import-no-settings-silent/src/Sources/Startup.twin similarity index 100% rename from bugs/import-no-settings-silent/src/Sources/Startup.twin rename to bugs/filed/import-no-settings-silent/src/Sources/Startup.twin diff --git a/bugs/filed/latebound-call-retried/REPORT.md b/bugs/filed/latebound-call-retried/REPORT.md new file mode 100644 index 000000000..d56f472a6 --- /dev/null +++ b/bugs/filed/latebound-call-retried/REPORT.md @@ -0,0 +1,42 @@ +Filed as [twinbasic/twinbasic#2491](https://github.com/twinbasic/twinbasic/issues/2491). + +## A late-bound call that passes arguments and fails is issued a second time, without them + +**Describe the bug** +When a late-bound call that passes arguments fails, twinBASIC calls `Invoke` a second time, as a property read (`wFlags` 3) with no arguments. A `Sub` called with an argument it does not take therefore runs twice before error 13 is raised, a failed property assignment runs the property's `Property Get` afterwards, and the error the caller sees is replaced. Observed in a run of the reproducer project, and with `Invoke` implemented by a class that records its calls. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `latebound-call-retried.twinproj` (attached as `latebound-call-retried.zip`). Its one source file, `Startup.twin`, holds a class and `Sub Main`. `Widget` has `Sub Hello()`, which counts how often it runs, and a `Property Let Prop` that raises error 5 beside a `Property Get Prop` that counts how often it runs. +2. Run it. `Main` calls `o.Hello 1` and `o.Prop = 1` through `Dim o As Object = New Widget`, with `On Error Resume Next`, and prints the error and the counts after each: + ``` + o.Hello 1: error 13, ran 2 time(s), Property Get ran 0 + o.Boom 1: error 5, ran 1 time(s), Property Get ran 0 + x = o.BoomFn(1): error 5, ran 1 time(s), Property Get ran 0 + o.Prop = 1: error -2147352567, ran 1 time(s), Property Get ran 1 + w.Boom 1 (early bound): error 5, ran 1 time(s), Property Get ran 0 + ``` +3. See `Hello` run twice, and `Property Get Prop` run after the `Property Let` that failed. The error from the assignment is `&H80020009` (`DISP_E_EXCEPTION`) where the `Property Let` raised 5. +4. To see the second call, implement `IDispatch` in a `NotDispatchable` class whose `Invoke` prints `wFlags`, `pDispParams.cArgs` and whether `pVarResult` is null, and returns a failure code. With `Fail3` returning `DISP_E_TYPEMISMATCH` and `Fail5` raising error 5: + ``` + o.Fail3 1 Invoke flags=1 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set + o.Fail3 = 5 Invoke flags=4 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set + o.Fail5 = 5 Invoke flags=4 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set + Set o.Fail1 = e Invoke flags=8 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set + ``` + +**Expected behavior** +One `Invoke` per late-bound call, as a raw `Invoke` does: calling `Invoke` directly with the same argument runs `Hello` once and returns `DISP_E_TYPEMISMATCH`. In VB6 the same two statements fail without running anything the callee counts: `o.Hello 1` raises error 450, *Wrong number of arguments or invalid property assignment*, with `Hello` run 0 times, and `o.Prop = 1` raises error 5 from the `Property Let` with the `Property Get` run 0 times. The VB6 project is attached as `latebound-call-retried-vb6.zip`; it prints `o.Hello 1 -> error 450 (1C2) [Wrong number of arguments or invalid property assignment]` with `Hits=0`, and `o.Prop = 1 -> error 5 (5) [Invalid procedure call or argument]` with `Hits=1 Gets=0` (the one run is the `Property Let`). At the least a failed call must not run the callee again, and the error of the assignment must be the one the `Property Let` raised. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: a late-bound call with a wrong argument count has side effects twice, and a late-bound property assignment that fails calls the getter, which may be expensive or have effects of its own. + +What does not reproduce it: a statement with no arguments (`o.Fail3`, `o.Hello`, `x = o.Fail3`); a statement that raises from inside the callee (`o.Boom 1` and `x = o.BoomFn(1)` run once and keep error 5); `CallByName` with an argument, for every call type (one `Invoke`); a call that succeeds; early-bound calls. What does reproduce it: `o.Hello 1`, `o.Hello(1)`, `o.Hello 1, 2`, the same through a `Variant` holding the object, and `o.Fn 1` for a `Function` with no parameters (ran twice, error 13). An assignment is repeated whatever the failure was (`DISP_E_TYPEMISMATCH`, `DISP_E_MEMBERNOTFOUND`, `E_FAIL`, an error raised by the setter); a statement call is repeated after `DISP_E_TYPEMISMATCH`, but not after an error raised in the callee. + +The second call resembles VB's rule for `o.Member(args)` on a property that returns an object or a collection: read the property with no arguments, then apply the arguments to the result. It is applied after a failure of any call that has arguments. The single run of `Hello` in the raw `Invoke` case is also at odds with the COM contract, which gives `DISP_E_BADPARAMCOUNT` for too many arguments without running the member; it is left out of this entry. + + diff --git a/bugs/latebound-call-retried/latebound-call-retried.twinproj b/bugs/filed/latebound-call-retried/latebound-call-retried.twinproj similarity index 100% rename from bugs/latebound-call-retried/latebound-call-retried.twinproj rename to bugs/filed/latebound-call-retried/latebound-call-retried.twinproj diff --git a/bugs/latebound-call-retried/repro.json b/bugs/filed/latebound-call-retried/repro.json similarity index 90% rename from bugs/latebound-call-retried/repro.json rename to bugs/filed/latebound-call-retried/repro.json index 1711a9fcc..6f672e3c2 100644 --- a/bugs/latebound-call-retried/repro.json +++ b/bugs/filed/latebound-call-retried/repro.json @@ -6,5 +6,6 @@ "^o\\.Hello 1: error 13, ran 2 time\\(s\\)", "^o\\.Prop = 1: error -2147352567, ran 1 time\\(s\\), Property Get ran 1$" ] - } + }, + "issue": 2491 } diff --git a/bugs/latebound-call-retried/src/Settings b/bugs/filed/latebound-call-retried/src/Settings similarity index 100% rename from bugs/latebound-call-retried/src/Settings rename to bugs/filed/latebound-call-retried/src/Settings diff --git a/bugs/latebound-call-retried/src/Sources/Startup.twin b/bugs/filed/latebound-call-retried/src/Sources/Startup.twin similarity index 100% rename from bugs/latebound-call-retried/src/Sources/Startup.twin rename to bugs/filed/latebound-call-retried/src/Sources/Startup.twin diff --git a/bugs/latebound-call-retried/vb6/Module1.bas b/bugs/filed/latebound-call-retried/vb6/Module1.bas similarity index 100% rename from bugs/latebound-call-retried/vb6/Module1.bas rename to bugs/filed/latebound-call-retried/vb6/Module1.bas diff --git a/bugs/latebound-call-retried/vb6/Probe.vbp b/bugs/filed/latebound-call-retried/vb6/Probe.vbp similarity index 100% rename from bugs/latebound-call-retried/vb6/Probe.vbp rename to bugs/filed/latebound-call-retried/vb6/Probe.vbp diff --git a/bugs/latebound-call-retried/vb6/Widget.cls b/bugs/filed/latebound-call-retried/vb6/Widget.cls similarity index 100% rename from bugs/latebound-call-retried/vb6/Widget.cls rename to bugs/filed/latebound-call-retried/vb6/Widget.cls diff --git a/bugs/filed/latebound-unknown-member-error/REPORT.md b/bugs/filed/latebound-unknown-member-error/REPORT.md new file mode 100644 index 000000000..5b2271373 --- /dev/null +++ b/bugs/filed/latebound-unknown-member-error/REPORT.md @@ -0,0 +1,41 @@ +Filed as [twinbasic/twinbasic#2490](https://github.com/twinbasic/twinbasic/issues/2490). + +## A late-bound call to a member that does not exist raises &H80020006, and `CallByName` raises &H80004005, where VB6 raises 438 + +**Describe the bug** +Calling a member that an object does not have, through an `Object` variable, raises error `&H80020006` (`DISP_E_UNKNOWNNAME`, *Unknown name.*) instead of 438, *Object doesn't support this property or method*. `CallByName` with the same name raises `&H80004005`, *Unspecified error*. An `On Error` handler written for VB6 or VBA, which tests for 438, does not catch either. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `latebound-unknown-member-error.twinproj` (attached as `latebound-unknown-member-error.zip`). Its one source file, `Startup.twin`, holds a class `Widget` with a `Sub Hello()` and a `Sub Main` that calls members through `Object` variables, with `On Error Resume Next`. +2. Run it and read the DEBUG CONSOLE: + ``` + o.Nope -> error -2147352570 (80020006) [Unknown name.] + Collection.Nope -> error -2147352570 (80020006) [Unknown name.] + CallByName o, Nope -> error -2147467259 (80004005) [Unspecified error] + o.Hello (control) -> error 0 (0) [] + ``` + +**Expected behavior** +Error 438 for all three, as in VB6 and VBA. The same cases in a VB6 project (a class, a `Variant` holding it, a `Collection`, a `Scripting.Dictionary`, and `CallByName` on each of them), attached as `latebound-unknown-member-error-vb6.zip`: +``` +o.Nope (class) -> error 438 (1B6) [Object doesn't support this property or method] +v.Nope (class in a Variant) -> error 438 (1B6) [Object doesn't support this property or method] +Collection.Nope -> error 438 (1B6) [Object doesn't support this property or method] +Dictionary.Nope -> error 438 (1B6) [Object doesn't support this property or method] +CallByName class Nope -> error 438 (1B6) [Object doesn't support this property or method] +CallByName Dictionary Nope -> error 438 (1B6) [Object doesn't support this property or method] +CallByName Collection Nope -> error 438 (1B6) [Object doesn't support this property or method] +``` +twinBASIC already maps `DISP_E_MEMBERNOTFOUND` from `Invoke` to 438; `DISP_E_UNKNOWNNAME` from `GetIDsOfNames`, which is the answer for a name the object does not have, should be mapped the same way, and `CallByName` should not turn it into `E_FAIL`. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: code ported from VB6 or VBA that probes an object for a member with `On Error` and 438 stops working, and the message names a COM code instead of the problem. + +The twinBASIC class's `GetIDsOfNames` itself is right: it returns `DISP_E_UNKNOWNNAME` for a name it does not know, as the contract says; the conversion into a run-time error is what differs. The same `&H80020006` came from a twinBASIC class, a `Collection`, a `Scripting.Dictionary` and a `Scripting.FileSystemObject`, and a `Variant` holding an object; `CallByName` gave `&H80004005` for a twinBASIC class and a `Dictionary`. Other failures are mapped as VB6 does: a call that `Invoke` rejects with `DISP_E_MEMBERNOTFOUND` raises 438, and a `Nothing` object raises 91. + + diff --git a/bugs/latebound-unknown-member-error/latebound-unknown-member-error.twinproj b/bugs/filed/latebound-unknown-member-error/latebound-unknown-member-error.twinproj similarity index 100% rename from bugs/latebound-unknown-member-error/latebound-unknown-member-error.twinproj rename to bugs/filed/latebound-unknown-member-error/latebound-unknown-member-error.twinproj diff --git a/bugs/latebound-unknown-member-error/repro.json b/bugs/filed/latebound-unknown-member-error/repro.json similarity index 92% rename from bugs/latebound-unknown-member-error/repro.json rename to bugs/filed/latebound-unknown-member-error/repro.json index 53fc46756..dcdd2a4ca 100644 --- a/bugs/latebound-unknown-member-error/repro.json +++ b/bugs/filed/latebound-unknown-member-error/repro.json @@ -7,5 +7,6 @@ "^Collection\\.Nope -> error -2147352570 \\(80020006\\)", "^CallByName o, Nope -> error -2147467259 \\(80004005\\)" ] - } + }, + "issue": 2490 } diff --git a/bugs/latebound-unknown-member-error/src/Settings b/bugs/filed/latebound-unknown-member-error/src/Settings similarity index 100% rename from bugs/latebound-unknown-member-error/src/Settings rename to bugs/filed/latebound-unknown-member-error/src/Settings diff --git a/bugs/latebound-unknown-member-error/src/Sources/Startup.twin b/bugs/filed/latebound-unknown-member-error/src/Sources/Startup.twin similarity index 100% rename from bugs/latebound-unknown-member-error/src/Sources/Startup.twin rename to bugs/filed/latebound-unknown-member-error/src/Sources/Startup.twin diff --git a/bugs/latebound-unknown-member-error/vb6/Module1.bas b/bugs/filed/latebound-unknown-member-error/vb6/Module1.bas similarity index 100% rename from bugs/latebound-unknown-member-error/vb6/Module1.bas rename to bugs/filed/latebound-unknown-member-error/vb6/Module1.bas diff --git a/bugs/latebound-unknown-member-error/vb6/Probe.vbp b/bugs/filed/latebound-unknown-member-error/vb6/Probe.vbp similarity index 100% rename from bugs/latebound-unknown-member-error/vb6/Probe.vbp rename to bugs/filed/latebound-unknown-member-error/vb6/Probe.vbp diff --git a/bugs/latebound-unknown-member-error/vb6/Widget.cls b/bugs/filed/latebound-unknown-member-error/vb6/Widget.cls similarity index 100% rename from bugs/latebound-unknown-member-error/vb6/Widget.cls rename to bugs/filed/latebound-unknown-member-error/vb6/Widget.cls diff --git a/bugs/filed/llvm-conversion-rounds-half-away/REPORT.md b/bugs/filed/llvm-conversion-rounds-half-away/REPORT.md new file mode 100644 index 000000000..15d9ecb41 --- /dev/null +++ b/bugs/filed/llvm-conversion-rounds-half-away/REPORT.md @@ -0,0 +1,35 @@ +Filed as [twinbasic/twinbasic#2469](https://github.com/twinbasic/twinbasic/issues/2469). + +## LLVM-compiled code rounds a `Single` or `Double` to an integer half away from zero, where VB6 and plain code round half to even + +**Describe the bug** +In a procedure compiled with LLVM, `CLng`, `CInt` and `CLngLng` of a `Single` or `Double` variable, an assignment of one to a `Long` or `Integer`, and the operand conversion of `\` round a value exactly halfway between two integers away from zero: `CLng(2.5)` of a `Double` variable is 3 and `CLng(-0.5)` is -1. The same code without LLVM rounds to the even integer, 2 and 0, as VB6 does. A value that is not a tie rounds the same everywhere. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `llvm-conversion-rounds-half-away.twinproj` (attached as `llvm-conversion-rounds-half-away.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its `[CompilerOptions("+llvm")]`, and `PlainCases` holds the same statements without it. Each procedure converts 0.5, 2.5, 3.5, -0.5 and -2.5. +2. Run it (F5). The DEBUG CONSOLE has one line per value and mode; for 2.5 and -0.5 they read: + ``` + plain 2.5: CLng(d)=2 CLng(s)=2 CInt(d)=2 CLngLng(d)=2 l=d:2 d\1=2 CLng(v)=2 Round=2 + plain -0.5: CLng(d)=0 CLng(s)=0 CInt(d)=0 CLngLng(d)=0 l=d:0 d\1=0 CLng(v)=0 Round=0 + LLVM 2.5: CLng(d)=3 CLng(s)=3 CInt(d)=3 CLngLng(d)=3 l=d:3 d\1=3 CLng(v)=2 Round=2 + LLVM -0.5: CLng(d)=-1 CLng(s)=-1 CInt(d)=-1 CLngLng(d)=-1 l=d:-1 d\1=-1 CLng(v)=0 Round=0 + ``` + +**Expected behavior** +The same result in both: round half to even, as in VB6 (attached as `llvm-conversion-rounds-half-away-vb6.zip`), which prints for the same values `CLng(d)=2` for 2.5 and `CLng(d)=0` for -0.5, `l=d:2`, `d\1=2`, `CLng(v)=2` and `Round=2`, `0` and `0`. VB6 has no `LongLong`, so that column is missing from its output. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically, and on win64. + +What does not differ in LLVM-compiled code: `CLng` of a `Variant` holding the `Double`, `Round`, `Int` and `Fix`, and conversions of a literal such as `CLng(2.5)` (folded to 2 by the compiler). + +A consequence: a value that rounds to zero in plain code can raise an error under LLVM. In a `Byte` conversion, `CByte(-0.5)` and `b = -0.5` (a `Double` variable) give 0 without LLVM and error 6, *Overflow*, with it, because the value becomes -1. + +Severity: a silent wrong result, at ties only, unless the rounding makes a conversion overflow. + + diff --git a/bugs/llvm-conversion-rounds-half-away/llvm-conversion-rounds-half-away.twinproj b/bugs/filed/llvm-conversion-rounds-half-away/llvm-conversion-rounds-half-away.twinproj similarity index 100% rename from bugs/llvm-conversion-rounds-half-away/llvm-conversion-rounds-half-away.twinproj rename to bugs/filed/llvm-conversion-rounds-half-away/llvm-conversion-rounds-half-away.twinproj diff --git a/bugs/llvm-conversion-rounds-half-away/repro.json b/bugs/filed/llvm-conversion-rounds-half-away/repro.json similarity index 94% rename from bugs/llvm-conversion-rounds-half-away/repro.json rename to bugs/filed/llvm-conversion-rounds-half-away/repro.json index 6dc062226..4b57886f9 100644 --- a/bugs/llvm-conversion-rounds-half-away/repro.json +++ b/bugs/filed/llvm-conversion-rounds-half-away/repro.json @@ -8,5 +8,6 @@ "^LLVM\\s+2\\.5:\\s+CLng\\(d\\)=3\\s+CLng\\(s\\)=3\\s+CInt\\(d\\)=3\\s+CLngLng\\(d\\)=3\\s+l=d:3\\s+d\\\\1=3\\s+CLng\\(v\\)=2\\s+Round=2$", "^LLVM\\s+-0\\.5:\\s+CLng\\(d\\)=-1\\s" ] - } + }, + "issue": 2469 } diff --git a/bugs/llvm-conversion-rounds-half-away/src/Settings b/bugs/filed/llvm-conversion-rounds-half-away/src/Settings similarity index 100% rename from bugs/llvm-conversion-rounds-half-away/src/Settings rename to bugs/filed/llvm-conversion-rounds-half-away/src/Settings diff --git a/bugs/llvm-conversion-rounds-half-away/src/Sources/Startup.twin b/bugs/filed/llvm-conversion-rounds-half-away/src/Sources/Startup.twin similarity index 100% rename from bugs/llvm-conversion-rounds-half-away/src/Sources/Startup.twin rename to bugs/filed/llvm-conversion-rounds-half-away/src/Sources/Startup.twin diff --git a/bugs/llvm-conversion-rounds-half-away/vb6/Module1.bas b/bugs/filed/llvm-conversion-rounds-half-away/vb6/Module1.bas similarity index 100% rename from bugs/llvm-conversion-rounds-half-away/vb6/Module1.bas rename to bugs/filed/llvm-conversion-rounds-half-away/vb6/Module1.bas diff --git a/bugs/llvm-conversion-rounds-half-away/vb6/Probe.vbp b/bugs/filed/llvm-conversion-rounds-half-away/vb6/Probe.vbp similarity index 100% rename from bugs/llvm-conversion-rounds-half-away/vb6/Probe.vbp rename to bugs/filed/llvm-conversion-rounds-half-away/vb6/Probe.vbp diff --git a/bugs/filed/llvm-double-overflow-no-error/REPORT.md b/bugs/filed/llvm-double-overflow-no-error/REPORT.md new file mode 100644 index 000000000..71edf8d3a --- /dev/null +++ b/bugs/filed/llvm-double-overflow-no-error/REPORT.md @@ -0,0 +1,55 @@ +Filed as [twinbasic/twinbasic#2477](https://github.com/twinbasic/twinbasic/issues/2477). + +## LLVM-compiled code raises no error when a `Double` overflows, where it raises error 6 otherwise + +**Describe the bug** +In a procedure compiled with LLVM, a `Double` addition, subtraction, multiplication or exponentiation whose result is too large for a `Double` raises no error: `Err.Number` stays 0 and the target holds infinity. The same statement in a procedure compiled without LLVM raises error 6 (*Overflow*), as VB6 does. A program that relies on error 6 to detect an overflow gets a silent `Inf` instead. No project setting and no other attribute is involved: the one difference is `[CompilerOptions("+llvm")]` on the procedure, or `tbrun --llvm` on the whole project. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `llvm-double-overflow-no-error.twinproj` (attached as `llvm-double-overflow-no-error.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its attribute. `PlainCases` holds the same statements without it. Each runs under `On Error Resume Next`, for example: + ``` + [CompilerOptions("+llvm")] + Private Sub LlvmCases() + Dim big As Double = 1E+308 + Dim r As Double + On Error Resume Next + r = 5#: Err.Clear: r = big * 10# + Debug.Print "LLVM Double * Err=" & Err.Number & " r=" & r + End Sub + ``` +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + plain Double * Err=6 r=1.#INF + plain Double + Err=6 r=1.#INF + plain Double ^ Err=6 + plain Double / Err=6 r=1.#INF + plain 1# / 0# Err=11 r=1.#INF + plain Long + Err=6 l=5 + LLVM Double * Err=0 r=1.#INF + LLVM Double + Err=0 r=1.#INF + LLVM Double ^ Err=0 + LLVM Double / Err=6 r=5 + LLVM 1# / 0# Err=11 r=5 + LLVM Long + Err=6 l=5 + ``` + +**Expected behavior** +Error 6 in LLVM-compiled code for every `Double` overflow, as in the same code without LLVM and in VB6 (attached as `llvm-double-overflow-no-error-vb6.zip`, which prints `Err=6` for the multiplication, the addition, the exponentiation and the division, and `Err=11` for `1# / 0#`). + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically, and on win32 and win64. Running the reproducer with `--llvm` compiles every procedure with LLVM and gives the `LLVM` results for both halves. With `[FloatingPointErrorChecks(True)]` on the LLVM procedure the multiplication still raises nothing. + +What does not reproduce it: a `Double` division that overflows (`1E+308 / 0.1`) raises 6, and so does `Exp(1000#)`, a `Long` overflow (`2147483647 + 1`) raises 6, and `1# / 0#` raises 11, all in LLVM-compiled code. So the overflow check is missing from the addition, subtraction, multiplication and `^` operators, not from the error path. + +A second difference shows in the same output. After a division that raises an error, LLVM-compiled code leaves the target unassigned: `r` is still 5 after `1# / 0#` (error 11) and after `1E+308 / 0.1` (error 6). Without LLVM the target is assigned infinity, `1.#INF`, and so it is in VB6 (`r=1.#INF` after each of its errors). Under `On Error Resume Next` the two builds then continue with different values. + +A `Single` addition, subtraction, multiplication or division that overflows raises no error without LLVM, where VB6 raises 6 (with LLVM the first three raise none either, and the division raises 6). That is a separate defect, with its own reproducer, in the entry "A `Single` that overflows raises no error, where VB6 raises error 6" (`bugs/single-overflow-no-error/`). + +Severity: a silent wrong result. An overflow that raises 6 in a debug run and no error in the LLVM-compiled build changes what a program computes, and nothing marks the difference. + + diff --git a/bugs/llvm-double-overflow-no-error/llvm-double-overflow-no-error.twinproj b/bugs/filed/llvm-double-overflow-no-error/llvm-double-overflow-no-error.twinproj similarity index 100% rename from bugs/llvm-double-overflow-no-error/llvm-double-overflow-no-error.twinproj rename to bugs/filed/llvm-double-overflow-no-error/llvm-double-overflow-no-error.twinproj diff --git a/bugs/llvm-double-overflow-no-error/repro.json b/bugs/filed/llvm-double-overflow-no-error/repro.json similarity index 94% rename from bugs/llvm-double-overflow-no-error/repro.json rename to bugs/filed/llvm-double-overflow-no-error/repro.json index 608117a1b..792591ad9 100644 --- a/bugs/llvm-double-overflow-no-error/repro.json +++ b/bugs/filed/llvm-double-overflow-no-error/repro.json @@ -11,5 +11,6 @@ "^LLVM\\s+Double /\\s+Err=6 r=5$", "^LLVM\\s+1# / 0#\\s+Err=11 r=5$" ] - } + }, + "issue": 2477 } diff --git a/bugs/llvm-double-overflow-no-error/src/Settings b/bugs/filed/llvm-double-overflow-no-error/src/Settings similarity index 100% rename from bugs/llvm-double-overflow-no-error/src/Settings rename to bugs/filed/llvm-double-overflow-no-error/src/Settings diff --git a/bugs/llvm-double-overflow-no-error/src/Sources/Startup.twin b/bugs/filed/llvm-double-overflow-no-error/src/Sources/Startup.twin similarity index 100% rename from bugs/llvm-double-overflow-no-error/src/Sources/Startup.twin rename to bugs/filed/llvm-double-overflow-no-error/src/Sources/Startup.twin diff --git a/bugs/llvm-double-overflow-no-error/vb6/Module1.bas b/bugs/filed/llvm-double-overflow-no-error/vb6/Module1.bas similarity index 100% rename from bugs/llvm-double-overflow-no-error/vb6/Module1.bas rename to bugs/filed/llvm-double-overflow-no-error/vb6/Module1.bas diff --git a/bugs/llvm-double-overflow-no-error/vb6/Probe.vbp b/bugs/filed/llvm-double-overflow-no-error/vb6/Probe.vbp similarity index 100% rename from bugs/llvm-double-overflow-no-error/vb6/Probe.vbp rename to bugs/filed/llvm-double-overflow-no-error/vb6/Probe.vbp diff --git a/bugs/filed/llvm-shift-count-truncated/REPORT.md b/bugs/filed/llvm-shift-count-truncated/REPORT.md new file mode 100644 index 000000000..a1a1f37d5 --- /dev/null +++ b/bugs/filed/llvm-shift-count-truncated/REPORT.md @@ -0,0 +1,45 @@ +Filed as [twinbasic/twinbasic#2468](https://github.com/twinbasic/twinbasic/issues/2468). + +## In LLVM-compiled code a shift count is cut to the width of the shifted `Byte` or `Integer` + +**Describe the bug** +In a procedure compiled with LLVM, the count of `<<` or `>>` is reduced to the width of the shifted type before the shift, for a `Byte` and an `Integer`: `b << 256` with `b` a `Byte` of `&H81` leaves `&H81`, and `b >> 257` gives `&H40`, because the count becomes 0 and 1. Without LLVM both give 0, as the documentation says: a shift by as many bits as the type holds, or more, yields 0. An `Integer` shifted by 65536 or more behaves the same way. A `Long` is not affected, as the count is itself a `Long`. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `llvm-shift-count-truncated.twinproj` (attached as `llvm-shift-count-truncated.zip`). It needs an LLVM licence (Ultimate): `LlvmCases` is compiled with LLVM by its `[CompilerOptions("+llvm")]`, and `PlainCases` holds the same statements without it. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + plain Byte << 256 0 + plain Byte >> 257 0 + plain Integer << 65536 0 + plain Integer >> 65537 0 + plain Long << 65536 0 + plain Long << -1 80000000 + plain Long >> -1 FFFFFFFF + LLVM Byte << 256 81 + LLVM Byte >> 257 40 + LLVM Integer << 65536 8001 + LLVM Integer >> 65537 C000 + LLVM Long << 65536 0 + LLVM Long << -1 0 + LLVM Long >> -1 0 + ``` + +**Expected behavior** +0 for a `Byte` shifted by 8 or more, and for an `Integer` shifted by 16 or more, whatever the count, in LLVM-compiled code as without LLVM (docs/Reference/Core/LeftShift.md and RightShift.md: "A shift by as many bits as the type holds, or more, yields 0"). VB6 has no shift operators, so there is no VB6 project. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically, and on win64. + +Counts from 8 to 255 for a `Byte`, and 16 to 65535 for an `Integer`, give 0 in both modes. The difference starts where the count, taken modulo 256 or 65536, is below the width. + +A negative count also differs between the two modes, and the documentation calls the result of a negative count "no useful result". Without LLVM the count is reduced modulo 32 for every type (`1& << -1` is `&H80000000`, `&H80000001 >> -1` is `&HFFFFFFFF`); with LLVM a negative count gives 0 (a `Long`, `LongLong` or `Integer`; a `Byte` gives 0 too). Neither is documented, so that part is a difference, not a departure. + +Severity: a silent wrong result for a `Byte` shifted by 256 or more, or an `Integer` by 65536 or more. Counts that large are rare. + + diff --git a/bugs/llvm-shift-count-truncated/llvm-shift-count-truncated.twinproj b/bugs/filed/llvm-shift-count-truncated/llvm-shift-count-truncated.twinproj similarity index 100% rename from bugs/llvm-shift-count-truncated/llvm-shift-count-truncated.twinproj rename to bugs/filed/llvm-shift-count-truncated/llvm-shift-count-truncated.twinproj diff --git a/bugs/llvm-shift-count-truncated/repro.json b/bugs/filed/llvm-shift-count-truncated/repro.json similarity index 93% rename from bugs/llvm-shift-count-truncated/repro.json rename to bugs/filed/llvm-shift-count-truncated/repro.json index ba7f8b8bc..526305b44 100644 --- a/bugs/llvm-shift-count-truncated/repro.json +++ b/bugs/filed/llvm-shift-count-truncated/repro.json @@ -10,5 +10,6 @@ "^LLVM\\s+Integer << 65536\\s+8001$", "^LLVM\\s+Integer >> 65537\\s+C000$" ] - } + }, + "issue": 2468 } diff --git a/bugs/llvm-shift-count-truncated/src/Settings b/bugs/filed/llvm-shift-count-truncated/src/Settings similarity index 100% rename from bugs/llvm-shift-count-truncated/src/Settings rename to bugs/filed/llvm-shift-count-truncated/src/Settings diff --git a/bugs/llvm-shift-count-truncated/src/Sources/Startup.twin b/bugs/filed/llvm-shift-count-truncated/src/Sources/Startup.twin similarity index 100% rename from bugs/llvm-shift-count-truncated/src/Sources/Startup.twin rename to bugs/filed/llvm-shift-count-truncated/src/Sources/Startup.twin diff --git a/bugs/filed/lset-variant-destination-types/REPORT.md b/bugs/filed/lset-variant-destination-types/REPORT.md new file mode 100644 index 000000000..7eea25fed --- /dev/null +++ b/bugs/filed/lset-variant-destination-types/REPORT.md @@ -0,0 +1,30 @@ +Filed as [twinbasic/twinbasic#2482](https://github.com/twinbasic/twinbasic/issues/2482). + +## `LSet` and `RSet` on a `Variant` destination raise 13 for `Null` and for an object, and pad a `Boolean` as `-1` + +**Describe the bug** +When the destination of `LSet` or `RSet` is a `Variant`, three subtypes behave differently from VB6. A `Variant` holding `Null` raises error 13 (*Type mismatch*), where VB6 raises 94 (*Invalid use of Null*). A `Variant` holding an object, such as a `Collection`, raises error 13, where VB6 raises no error. A `Variant` holding `True` is aligned as the two characters `-1`: `RSet v = "ab"` leaves `[ab]` with `Len` 2, where VB6 aligns within `"True"` and leaves `[ ab]` with `Len` 4. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `lset-variant-destination-types.twinproj` (attached as `lset-variant-destination-types.zip`). Its one source file, `Startup.twin`, has a `Sub Main` under `On Error Resume Next` that runs `LSet v = "abc"` on a `Variant` holding `Null`, `LSet v = "abc"` on one holding `New Collection`, and `RSet v = "ab"` on one holding `True`. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + LSet, destination Null: error 13 + LSet, destination Object: error 13 + RSet, destination True: [ab] Len 2 + ``` + +**Expected behavior** +`error 94` for `Null`, no error for the object, and `[ ab] Len 4` for `True`, as in VB6 (attached as `lset-variant-destination-types-vb6.zip`, which prints `error 94`, `error 0` and `[ ab] Len 4`). + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low. Programs rarely align into a `Variant` that holds `Null` or an object, and the error number matters only to a handler that tests for 94. + +A `Variant` holding an `Integer`, a `Long`, a `Double` or a `Date` is aligned within its text as in VB6, and one holding an error value or an array raises 13 in both. A `Null` source into a `String` destination raises 94 in both. The same results on BETA 983. + + diff --git a/bugs/lset-variant-destination-types/lset-variant-destination-types.twinproj b/bugs/filed/lset-variant-destination-types/lset-variant-destination-types.twinproj similarity index 100% rename from bugs/lset-variant-destination-types/lset-variant-destination-types.twinproj rename to bugs/filed/lset-variant-destination-types/lset-variant-destination-types.twinproj diff --git a/bugs/lset-variant-destination-types/repro.json b/bugs/filed/lset-variant-destination-types/repro.json similarity index 91% rename from bugs/lset-variant-destination-types/repro.json rename to bugs/filed/lset-variant-destination-types/repro.json index 569ba47b6..03fb20128 100644 --- a/bugs/lset-variant-destination-types/repro.json +++ b/bugs/filed/lset-variant-destination-types/repro.json @@ -7,5 +7,6 @@ "^LSet, destination Object: error 13$", "^RSet, destination True: \\[ab\\] Len 2$" ] - } + }, + "issue": 2482 } diff --git a/bugs/lset-variant-destination-types/src/Settings b/bugs/filed/lset-variant-destination-types/src/Settings similarity index 100% rename from bugs/lset-variant-destination-types/src/Settings rename to bugs/filed/lset-variant-destination-types/src/Settings diff --git a/bugs/lset-variant-destination-types/src/Sources/Startup.twin b/bugs/filed/lset-variant-destination-types/src/Sources/Startup.twin similarity index 100% rename from bugs/lset-variant-destination-types/src/Sources/Startup.twin rename to bugs/filed/lset-variant-destination-types/src/Sources/Startup.twin diff --git a/bugs/lset-variant-destination-types/vb6/Module1.bas b/bugs/filed/lset-variant-destination-types/vb6/Module1.bas similarity index 100% rename from bugs/lset-variant-destination-types/vb6/Module1.bas rename to bugs/filed/lset-variant-destination-types/vb6/Module1.bas diff --git a/bugs/lset-variant-destination-types/vb6/Probe.vbp b/bugs/filed/lset-variant-destination-types/vb6/Probe.vbp similarity index 100% rename from bugs/lset-variant-destination-types/vb6/Probe.vbp rename to bugs/filed/lset-variant-destination-types/vb6/Probe.vbp diff --git a/bugs/filed/math-intrinsics-ignore-argument-names/REPORT.md b/bugs/filed/math-intrinsics-ignore-argument-names/REPORT.md new file mode 100644 index 000000000..35c8a0c71 --- /dev/null +++ b/bugs/filed/math-intrinsics-ignore-argument-names/REPORT.md @@ -0,0 +1,37 @@ +Filed as [twinbasic/twinbasic#2473](https://github.com/twinbasic/twinbasic/issues/2473). + +## `ShiftRotateLeft`, `ShiftRotateRight`, `ShiftUnsignedRight` and `ByteSwap` ignore argument names and take their arguments by position + +**Describe the bug** +The four new intrinsics of the VBA package (BETA 997) take their arguments by position and discard the names a call gives. `ShiftRotateLeft(ShiftAmount:=4, Number:=n)` uses 4 as the number and `n` as the shift amount, with no error, so the result is wrong. A name that matches no parameter, as in `ShiftRotateLeft(Foo:=n, Bar:=k)`, is accepted too. An ordinary procedure honours the names. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `math-intrinsics-ignore-argument-names.twinproj` (attached as `math-intrinsics-ignore-argument-names.zip`). Its `Sub Main` calls each intrinsic with a `Long` `n` of `&H12345678` and `k` of 4, in several forms. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + positional 23456781 + named, in order 23456781 + named, swapped 4000000 + right, named, swapped 400 + unsigned, named, swapped 0 + unknown names 23456781 + ByteSwap, unknown name 78563412 + own function, swapped 23456781 + ``` + +**Expected behavior** +The names select the parameters, as they do for any procedure (the last line, a function of the project with the same parameter names): `ShiftRotateLeft(ShiftAmount:=4, Number:=n)` is `23456781`, `ShiftRotateRight` of the same is `81234567` and `ShiftUnsignedRight` is `01234567`. A name that matches no parameter is a compile error, as it is in VB6 (*Named argument not found*). + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +The four intrinsics are new in BETA 997; BETA 995 does not know them (TB5079). VB6 has no equivalent functions, so there is no VB6 project; the VB6 behaviour quoted is for a named argument that matches no parameter. + +The declared parameters are `Number` and `ShiftAmount` (`ByteSwap` has `Number`). `Compilation.UnrollLoop Bogus:=4` is accepted in the same way. Calls that give the arguments in declaration order, with or without the names, work. + +Severity: a silent wrong result, but only for a call that names its arguments out of declaration order or misspells a name. + + diff --git a/bugs/math-intrinsics-ignore-argument-names/math-intrinsics-ignore-argument-names.twinproj b/bugs/filed/math-intrinsics-ignore-argument-names/math-intrinsics-ignore-argument-names.twinproj similarity index 100% rename from bugs/math-intrinsics-ignore-argument-names/math-intrinsics-ignore-argument-names.twinproj rename to bugs/filed/math-intrinsics-ignore-argument-names/math-intrinsics-ignore-argument-names.twinproj diff --git a/bugs/math-intrinsics-ignore-argument-names/repro.json b/bugs/filed/math-intrinsics-ignore-argument-names/repro.json similarity index 94% rename from bugs/math-intrinsics-ignore-argument-names/repro.json rename to bugs/filed/math-intrinsics-ignore-argument-names/repro.json index cc4748aad..47197e08a 100644 --- a/bugs/math-intrinsics-ignore-argument-names/repro.json +++ b/bugs/filed/math-intrinsics-ignore-argument-names/repro.json @@ -12,5 +12,6 @@ "^ByteSwap, unknown name\\s+78563412$", "^own function, swapped\\s+23456781$" ] - } + }, + "issue": 2473 } diff --git a/bugs/math-intrinsics-ignore-argument-names/src/Settings b/bugs/filed/math-intrinsics-ignore-argument-names/src/Settings similarity index 100% rename from bugs/math-intrinsics-ignore-argument-names/src/Settings rename to bugs/filed/math-intrinsics-ignore-argument-names/src/Settings diff --git a/bugs/math-intrinsics-ignore-argument-names/src/Sources/Startup.twin b/bugs/filed/math-intrinsics-ignore-argument-names/src/Sources/Startup.twin similarity index 100% rename from bugs/math-intrinsics-ignore-argument-names/src/Sources/Startup.twin rename to bugs/filed/math-intrinsics-ignore-argument-names/src/Sources/Startup.twin diff --git a/bugs/filed/module-named-like-project/REPORT.md b/bugs/filed/module-named-like-project/REPORT.md new file mode 100644 index 000000000..55ebae973 --- /dev/null +++ b/bugs/filed/module-named-like-project/REPORT.md @@ -0,0 +1,44 @@ +Filed as [twinbasic/twinbasic#2494](https://github.com/twinbasic/twinbasic/issues/2494). + +## A `[RunAfterBuild]` Sub does not run, and nothing says so, when a module is named like the project + +**Describe the bug** +When a module has the same name as the project, a `[RunAfterBuild]` Sub does not run, and nothing says so. The build succeeds and the exe is written. The DEBUG CONSOLE's last line is `[BUILD] Executing '..'...`, and nothing follows it: no message box, no entry in the error panel, no diagnostic, and the status bar is as it is after a run that works. The names are compared without regard to letter case, so `ProbeWS` and `probews` clash as well. Even an empty module with the project's name, beside the module that holds the Sub, stops it. Observed in the DEBUG CONSOLE after pressing Build. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `module-named-like-project.twinproj` (attached as `module-named-like-project.zip`). The project is named `ModuleNamedLikeProject` and has one module of that name: + ``` + Module ModuleNamedLikeProject + Public Sub Main() + End Sub + [RunAfterBuild] + Public Sub Hello() + Debug.Cls + Debug.Print "RunAfterBuild ran" + End Sub + End Module + ``` +2. Build the project. +3. Read the DEBUG CONSOLE. It ends at `[BUILD] Executing 'ModuleNamedLikeProject.ModuleNamedLikeProject.Hello'...`. `RunAfterBuild ran` is never printed. +4. Rename the module, to `Runner` for example, and build again. The console is cleared and holds `RunAfterBuild ran`. + +**Expected behavior** +The Sub runs whatever the modules are called, as it does once the module is renamed. If a module may not have the project's name, the compiler should refuse it with an error, as VB6 does, and not build the project and skip the Sub in silence. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically. + +VB6 refuses a project whose module has the project's name. Its build stops with `Name conflicts with existing module, project, or object library`, for a module that holds `Sub Main` (the project is attached as `module-named-like-project-vb6.zip`) and for an empty module of that name beside the module that holds `Sub Main` (checked, not attached). So VB6 reports the clash, where twinBASIC accepts it and skips the Sub. + +What does not reproduce it: with any other module name, such as `Runner`, the Sub runs. A project with no module of the project's name runs it, whichever module holds it. + +What reproduces it besides the plain case: the clash in another letter case, such as a project `ProbeWS` with a module `probews`; and an empty module with the project's name in a project whose `[RunAfterBuild]` Sub is in a different module, `Runner`, where the console ends at `[BUILD] Executing 'ProbeWS.Runner.'...`, which names an unambiguous Sub. So the cause is the module's name, not a call that cannot be told apart. + +Severity: moderate. A `[RunAfterBuild]` Sub runs after the exe is built, for example to sign it, and here it is skipped while the build reports success. Nothing points at the module's name as the cause, so the project's owner has to find it by renaming modules. + + diff --git a/bugs/module-named-like-project/module-named-like-project.twinproj b/bugs/filed/module-named-like-project/module-named-like-project.twinproj similarity index 100% rename from bugs/module-named-like-project/module-named-like-project.twinproj rename to bugs/filed/module-named-like-project/module-named-like-project.twinproj diff --git a/bugs/module-named-like-project/repro.json b/bugs/filed/module-named-like-project/repro.json similarity index 91% rename from bugs/module-named-like-project/repro.json rename to bugs/filed/module-named-like-project/repro.json index 41fd2997b..9615e9816 100644 --- a/bugs/module-named-like-project/repro.json +++ b/bugs/filed/module-named-like-project/repro.json @@ -9,5 +9,6 @@ "absent": [ "^RunAfterBuild ran$" ] - } + }, + "issue": 2494 } diff --git a/bugs/module-named-like-project/src/Settings b/bugs/filed/module-named-like-project/src/Settings similarity index 100% rename from bugs/module-named-like-project/src/Settings rename to bugs/filed/module-named-like-project/src/Settings diff --git a/bugs/module-named-like-project/src/Sources/ModuleNamedLikeProject.twin b/bugs/filed/module-named-like-project/src/Sources/ModuleNamedLikeProject.twin similarity index 100% rename from bugs/module-named-like-project/src/Sources/ModuleNamedLikeProject.twin rename to bugs/filed/module-named-like-project/src/Sources/ModuleNamedLikeProject.twin diff --git a/bugs/module-named-like-project/vb6/Module1.bas b/bugs/filed/module-named-like-project/vb6/Module1.bas similarity index 100% rename from bugs/module-named-like-project/vb6/Module1.bas rename to bugs/filed/module-named-like-project/vb6/Module1.bas diff --git a/bugs/module-named-like-project/vb6/Probe.vbp b/bugs/filed/module-named-like-project/vb6/Probe.vbp similarity index 100% rename from bugs/module-named-like-project/vb6/Probe.vbp rename to bugs/filed/module-named-like-project/vb6/Probe.vbp diff --git a/bugs/filed/new-on-interface/REPORT.md b/bugs/filed/new-on-interface/REPORT.md new file mode 100644 index 000000000..91afeb431 --- /dev/null +++ b/bugs/filed/new-on-interface/REPORT.md @@ -0,0 +1,35 @@ +Filed as [twinbasic/twinbasic#2486](https://github.com/twinbasic/twinbasic/issues/2486). + +## `New` on an `Interface` compiles, and a call on the object returns a default value, or ends in an access violation for an inherited member + +**Describe the bug** +`New` is accepted on an `Interface`, although no class exists for it, and the statement returns an object that is not `Nothing` and has no implementation behind its members. A call on a member the interface declares itself returns the default for its type (0 for a `Long`, an empty string, `Nothing` for an interface) and raises no error. A call on a member the interface inherits from another `Interface` ends the run with a native `ACCESS_VIOLATION`. `New stdole.IUnknown` and `New stdole.IDispatch` are refused with TB5074 (*Class construction expected class datatype*), so the check exists for an interface from a type library, and is missing for one declared in twinBASIC source, in a project or a package. Observed in a run of the reproducer project; found with `ErrorContext` of the VBRUN package, an interface with no class, and `ErrorCallstack` does the same. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `new-on-interface.twinproj` (attached as `new-on-interface.zip`). Its one source file, `Startup.twin`, declares `Interface IParent` with one `Function F() As Long` and `Interface IChild Extends IParent` with one `Function G() As Long`, and a `Sub Main` that constructs `New IParent`, `New ErrorContext` and `New IChild` and calls their members. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + IParent: Nothing? False, F() = 0 + ErrorContext: Unknown, Number = 0, Callstack Nothing? True + IChild.G() = 0 + IChild.F(), inherited, next + NATIVE EXCEPTION: ACCESS_VIOLATION /Startup.twin; Startup.Main LINE 000029 [$00000000] + ``` + The project compiles without a diagnostic. `ObjPtr` of each object is not 0. `ErrorContext.Number` and `.State` read 0, `.Description` and `.Source` read an empty string, and `.Callstack` is `Nothing`, so `e.Callstack.Count` raises error 91, as any call on `Nothing` does. That error 91 is not a second defect: an unhandled one ends a run, as it does for a `Collection` variable that is `Nothing`, and under `On Error Resume Next` it skips the statement. + +**Expected behavior** +A compile error, TB5074, for `New` on any `Interface`, as for `stdole.IUnknown`: an interface is a contract, and `New` needs a class that implements it. If an object is built nevertheless, a call on any of its members should behave the same way, not return a default for one member and crash on another. Code written against the VBRUN documentation, which lists `ErrorContext` with its members, compiles and then reads zeros, where a refusal would say at once that nothing can create the object. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low in practice, since few programs write `New` on an interface, but it is a compile-time check that is missing, and the result is a silent wrong value or a crash. + +What was tried, each in a project of its own: `New` is accepted on an interface declared with `Extends stdole.IUnknown`, with no `Extends`, with `Extends stdole.IDispatch`, with `Extends` another interface of the project, and on a `Private` one, and `Dim x As New IFoo` is accepted as well. `TypeName` gives `Unknown` for an interface that extends `stdole.IUnknown` (and for `ErrorContext` and `ErrorCallstack`), and the interface's own name for one with no `Extends` or with `Extends stdole.IDispatch`. A `String` member returns an empty string, a `Property Get` returns 0, a `Sub` returns without a sign, and a member returning the interface itself returns `Nothing`. Only inherited members crash. `New stdole.IUnknown` and `New stdole.IDispatch` are refused. The same results on BETA 983. + +VB6 has no comparison: a project cannot declare an `Interface`, and the interfaces of the type libraries it references (`stdole.IUnknown`, `IPictureDisp`, `IFontDisp`) are hidden from it (*User-defined type not defined*). + + diff --git a/bugs/new-on-interface/new-on-interface.twinproj b/bugs/filed/new-on-interface/new-on-interface.twinproj similarity index 100% rename from bugs/new-on-interface/new-on-interface.twinproj rename to bugs/filed/new-on-interface/new-on-interface.twinproj diff --git a/bugs/new-on-interface/repro.json b/bugs/filed/new-on-interface/repro.json similarity index 93% rename from bugs/new-on-interface/repro.json rename to bugs/filed/new-on-interface/repro.json index cedaf65bb..800e4bc91 100644 --- a/bugs/new-on-interface/repro.json +++ b/bugs/filed/new-on-interface/repro.json @@ -9,5 +9,6 @@ "^IChild\\.F\\(\\), inherited, next$", "NATIVE EXCEPTION: ACCESS_VIOLATION" ] - } + }, + "issue": 2486 } diff --git a/bugs/new-on-interface/src/Settings b/bugs/filed/new-on-interface/src/Settings similarity index 100% rename from bugs/new-on-interface/src/Settings rename to bugs/filed/new-on-interface/src/Settings diff --git a/bugs/new-on-interface/src/Sources/Startup.twin b/bugs/filed/new-on-interface/src/Sources/Startup.twin similarity index 100% rename from bugs/new-on-interface/src/Sources/Startup.twin rename to bugs/filed/new-on-interface/src/Sources/Startup.twin diff --git a/bugs/filed/on-property-ignored/REPORT.md b/bugs/filed/on-property-ignored/REPORT.md new file mode 100644 index 000000000..9e8dc95d4 --- /dev/null +++ b/bugs/filed/on-property-ignored/REPORT.md @@ -0,0 +1,42 @@ +Filed as [twinbasic/twinbasic#2478](https://github.com/twinbasic/twinbasic/issues/2478). + +## Setting a property whose name starts with `on` through `HtmlElementProperties` does nothing, and raises no error + +**Describe the bug** +An add-in that sets an event-handler property of a tool-window element through `HtmlElementProperties`, such as `.Properties.onclick = "..."`, gets no error, and the element has no handler: a click does nothing, and the element has neither an `onclick` property nor an `onclick` attribute. The same handler written inline in `innerHTML` runs. The IDE page's property setter skips any property whose last name starts with `on`, with an empty branch (`if(a.indexOf("on")==0){}`), and reports success. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `on-property-ignored.twinproj` (attached as `on-property-ignored.zip`). It is an add-in, and its tool window is built like this: + ``` + With .Add("property", "div") + .Properties.innerText = "[property]" + On Error Resume Next + .Properties.onclick = "this.innerText = 'clicked'" + Host.DebugConsole.PrintText "onclick set, error " & Err.Number + On Error GoTo 0 + End With + With .Add("inline", "div") + .Properties.innerHTML = "[inline]" + End With + ``` +2. Build it (win32). Copy `Build\OnPropertyIgnored_win32.dll` into `%APPDATA%\twinBASIC\addins\win32\`, restart the IDE and open any project. +3. Click the toolbar button **on property repro**. A tool window opens with **[property]** and **[inline]**, and the DEBUG CONSOLE prints `onclick set, error 0`. +4. Click **[property]**. Nothing happens. +5. Click **[inline]**. Its text changes to `clicked`. + +**Expected behavior** +Either the property is set, as every other property is, and step 4 changes the text; or, if handler properties are refused on purpose, the assignment raises an error that says so, and step 3 prints a nonzero error. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995. + +Only `onclick` was tried. The setter's test is on the name, so it applies to every name that starts with `on`. + +Severity: low. `AddEventListener` and inline handlers work, but an add-in that sets a handler property gets neither the handler nor an error. + + diff --git a/bugs/on-property-ignored/on-property-ignored.twinproj b/bugs/filed/on-property-ignored/on-property-ignored.twinproj similarity index 100% rename from bugs/on-property-ignored/on-property-ignored.twinproj rename to bugs/filed/on-property-ignored/on-property-ignored.twinproj diff --git a/bugs/on-property-ignored/repro.json b/bugs/filed/on-property-ignored/repro.json similarity index 92% rename from bugs/on-property-ignored/repro.json rename to bugs/filed/on-property-ignored/repro.json index 312335325..4ee9e08d4 100644 --- a/bugs/on-property-ignored/repro.json +++ b/bugs/filed/on-property-ignored/repro.json @@ -4,5 +4,6 @@ "tests": [ "P4: a property whose name starts with \"on\" is dropped, and the add-in is told nothing" ], - "steps": "1. Open on-property-ignored.twinproj in the twinBASIC IDE and build it (win32). Copy Build\\OnPropertyIgnored_win32.dll into %APPDATA%\\twinBASIC\\addins\\win32\\, restart the IDE and open any project.\n2. Click the toolbar button on property repro. A tool window opens with [property] and [inline], and the DEBUG CONSOLE prints onclick set, error 0.\n3. Click [property]. Expected: its text changes to clicked, or step 2 raised an error. Actual: nothing happens; the element has no onclick handler and no onclick attribute.\n4. Control: click [inline]. Its text changes to clicked.\nAsserted on BETA 995 and 997 by `addin-test.bat --only panes` (P4, the PanesProbe add-in in test/addin/probes/panes, of which this project is a cut-down copy)." + "steps": "1. Open on-property-ignored.twinproj in the twinBASIC IDE and build it (win32). Copy Build\\OnPropertyIgnored_win32.dll into %APPDATA%\\twinBASIC\\addins\\win32\\, restart the IDE and open any project.\n2. Click the toolbar button on property repro. A tool window opens with [property] and [inline], and the DEBUG CONSOLE prints onclick set, error 0.\n3. Click [property]. Expected: its text changes to clicked, or step 2 raised an error. Actual: nothing happens; the element has no onclick handler and no onclick attribute.\n4. Control: click [inline]. Its text changes to clicked.\nAsserted on BETA 995 and 997 by `addin-test.bat --only panes` (P4, the PanesProbe add-in in test/addin/probes/panes, of which this project is a cut-down copy).", + "issue": 2478 } diff --git a/bugs/on-property-ignored/src/Settings b/bugs/filed/on-property-ignored/src/Settings similarity index 100% rename from bugs/on-property-ignored/src/Settings rename to bugs/filed/on-property-ignored/src/Settings diff --git a/bugs/on-property-ignored/src/Sources/Startup.twin b/bugs/filed/on-property-ignored/src/Sources/Startup.twin similarity index 100% rename from bugs/on-property-ignored/src/Sources/Startup.twin rename to bugs/filed/on-property-ignored/src/Sources/Startup.twin diff --git a/bugs/filed/raiseevent-plain-html-typeerror/REPORT.md b/bugs/filed/raiseevent-plain-html-typeerror/REPORT.md new file mode 100644 index 000000000..d8094a2d3 --- /dev/null +++ b/bugs/filed/raiseevent-plain-html-typeerror/REPORT.md @@ -0,0 +1,39 @@ +Filed as [twinbasic/twinbasic#2479](https://github.com/twinbasic/twinbasic/issues/2479). + +## `raiseEvent` in a tool window's own HTML throws a TypeError, and the add-in's listener is not called + +**Describe the bug** +An add-in sets a tool window's HTML with an inline handler that calls `raiseEvent`, as the list-view items of Sample 15 do, and registers a listener for the event with `AddEventListener` on the parent element. Clicking the element does not call the listener, and the add-in is told nothing. The IDE's page throws `TypeError: Cannot read properties of null (reading 'rootEventHandler')`: `raiseEvent` goes up through `parentNode` looking for an element that has `rootEventHandler`, and in plain tool-window HTML no element has one, so it goes past the document. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `raiseevent-plain-html-typeerror.twinproj` (attached as `raiseevent-plain-html-typeerror.zip`). It is an add-in, and its tool window is built like this: + ``` + With Pane.RootDomElement.ChildDomElements.Add("box", "div") + .AddEventListener("myEvent", AddressOf MyEvent) + .Properties.innerHTML = _ + "[raise] " & _ + "[direct]" + End With + ``` + `MyEvent` prints `myEvent from ` to the DEBUG CONSOLE. +2. Build it (win32). Copy `Build\RaiseeventPlainHtmlTypeerror_win32.dll` into `%APPDATA%\twinBASIC\addins\win32\`, restart the IDE and open any project. +3. Click the toolbar button **raiseEvent repro**. A tool window opens with **[raise]** and **[direct]**. +4. Click **[raise]**. Nothing is printed, and the IDE's page has the uncaught `TypeError` above. +5. Click **[direct]**. The DEBUG CONSOLE prints `myEvent from direct`. + +**Expected behavior** +Step 4 prints `myEvent from raise`: `raiseEvent` calls the listener the div registered, as calling it directly does in step 5. If `raiseEvent` is meant only for list views and Monaco widgets, it stops at the document without throwing. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995. + +The workaround is step 5's: an inline handler that calls the function `AddEventListener` stores on the element under the event's name. + +Severity: low. A workaround exists, but nothing tells the add-in's author why the listener is not called: the exception shows only in the IDE's own page. + + diff --git a/bugs/raiseevent-plain-html-typeerror/raiseevent-plain-html-typeerror.twinproj b/bugs/filed/raiseevent-plain-html-typeerror/raiseevent-plain-html-typeerror.twinproj similarity index 100% rename from bugs/raiseevent-plain-html-typeerror/raiseevent-plain-html-typeerror.twinproj rename to bugs/filed/raiseevent-plain-html-typeerror/raiseevent-plain-html-typeerror.twinproj diff --git a/bugs/raiseevent-plain-html-typeerror/repro.json b/bugs/filed/raiseevent-plain-html-typeerror/repro.json similarity index 97% rename from bugs/raiseevent-plain-html-typeerror/repro.json rename to bugs/filed/raiseevent-plain-html-typeerror/repro.json index e5b9efb2e..32e14b284 100644 --- a/bugs/raiseevent-plain-html-typeerror/repro.json +++ b/bugs/filed/raiseevent-plain-html-typeerror/repro.json @@ -4,5 +4,6 @@ "tests": [ "P12: raiseEvent from plain tool-window HTML throws, and the add-in hears nothing" ], - "steps": "1. Open raiseevent-plain-html-typeerror.twinproj in the twinBASIC IDE and build it (win32). Copy Build\\RaiseeventPlainHtmlTypeerror_win32.dll into %APPDATA%\\twinBASIC\\addins\\win32\\, restart the IDE and open any project.\n2. Click the toolbar button raiseEvent repro. A tool window opens with [raise] and [direct].\n3. Click [raise]. Expected: the DEBUG CONSOLE prints myEvent from raise. Actual: nothing is printed, and the IDE's page has an uncaught TypeError: Cannot read properties of null (reading 'rootEventHandler').\n4. Control: click [direct]. The DEBUG CONSOLE prints myEvent from direct.\nAsserted on BETA 995 and 997 by `addin-test.bat --only panes` (P12, the PanesProbe add-in in test/addin/probes/panes, of which this project is a cut-down copy)." + "steps": "1. Open raiseevent-plain-html-typeerror.twinproj in the twinBASIC IDE and build it (win32). Copy Build\\RaiseeventPlainHtmlTypeerror_win32.dll into %APPDATA%\\twinBASIC\\addins\\win32\\, restart the IDE and open any project.\n2. Click the toolbar button raiseEvent repro. A tool window opens with [raise] and [direct].\n3. Click [raise]. Expected: the DEBUG CONSOLE prints myEvent from raise. Actual: nothing is printed, and the IDE's page has an uncaught TypeError: Cannot read properties of null (reading 'rootEventHandler').\n4. Control: click [direct]. The DEBUG CONSOLE prints myEvent from direct.\nAsserted on BETA 995 and 997 by `addin-test.bat --only panes` (P12, the PanesProbe add-in in test/addin/probes/panes, of which this project is a cut-down copy).", + "issue": 2479 } diff --git a/bugs/raiseevent-plain-html-typeerror/src/Settings b/bugs/filed/raiseevent-plain-html-typeerror/src/Settings similarity index 100% rename from bugs/raiseevent-plain-html-typeerror/src/Settings rename to bugs/filed/raiseevent-plain-html-typeerror/src/Settings diff --git a/bugs/raiseevent-plain-html-typeerror/src/Sources/Startup.twin b/bugs/filed/raiseevent-plain-html-typeerror/src/Sources/Startup.twin similarity index 100% rename from bugs/raiseevent-plain-html-typeerror/src/Sources/Startup.twin rename to bugs/filed/raiseevent-plain-html-typeerror/src/Sources/Startup.twin diff --git a/bugs/filed/single-overflow-no-error/REPORT.md b/bugs/filed/single-overflow-no-error/REPORT.md new file mode 100644 index 000000000..2c3b2fbbb --- /dev/null +++ b/bugs/filed/single-overflow-no-error/REPORT.md @@ -0,0 +1,53 @@ +Filed as [twinbasic/twinbasic#2476](https://github.com/twinbasic/twinbasic/issues/2476). + +## A `Single` that overflows raises no error, where VB6 raises error 6 + +**Describe the bug** +When a `Single` addition, subtraction, multiplication or division produces a result too large for a `Single`, twinBASIC raises no error and the target holds infinity: `s = sbig + sbig` with `sbig` at 3E+38 leaves `Err.Number` at 0 and `s` at `1.#INF`. VB6 raises error 6 (*Overflow*) for the same statement. The same holds for `+=` and `*=`, for `Single * Long` and for some `Single` constant expressions. A `Double` overflow raises 6, so a `Single` and a `Double` are treated differently. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `single-overflow-no-error.twinproj` (attached as `single-overflow-no-error.zip`). Its `Sub Main` runs each case under `On Error Resume Next`, for example: + ``` + Dim sbig As Single = 3E+38! + Dim s As Single + On Error Resume Next + s = 5!: Err.Clear: s = sbig + sbig + Debug.Print "Single + Err=" & Err.Number & " s=" & s + ``` +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + Single + Err=0 s=1.#INF + Single - Err=0 s=-1.#INF + Single * Err=0 s=1.#INF + Single / Err=0 s=1.#INF + Single * Long Err=0 s=1.#INF + s = s + x Err=0 s=1.#INF + s += x Err=0 s=1.#INF + s = s * x Err=0 s=1.#INF + s *= x Err=0 s=1.#INF + CSng(Double) Err=6 s=5 + Single ^ Err=6 s=5 + Variant * Err=0 Double + Variant + Err=0 Double + ``` + +**Expected behavior** +Error 6 for every `Single` arithmetic result that does not fit a `Single`, as in VB6 (attached as `single-overflow-no-error-vb6.zip`, which prints `Err=6 s=1.#INF` for `+`, `-`, `*`, `/`, `Single * Long`, `s = s + x` and `s = s * x`). The target is `1.#INF` after the error in both, so only the error is missing. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically, and on win64 (which prints `inf` where win32 prints `1.#INF`). Compiled with LLVM, `+`, `-`, `*` and the compound forms are the same, and `/` raises 6, as in VB6. + +What matches VB6: a `Variant` holding a `Single` that overflows becomes a `Double` variant, with no error, in both (`Variant *`, `Variant +` above). `CSng` of a `Double` that is too large raises 6 in both. The result of `Single * Long` is a `Double` in both (`TypeName`), though twinBASIC stores it into a `Single` without an error. + +Where twinBASIC differs from VB6 in the other direction: `s = sbig ^ 2!` raises 6 in twinBASIC and gives `1.#INF` with no error in VB6, and so does assigning a `Double` that is too large to a `Single` (`s = 1E+300`: error 6 in twinBASIC, `1.#INF` with no error in VB6). + +A `Single` constant expression that overflows is refused by VB6 at compile time with *Overflow*. twinBASIC compiles some forms (`Const A As Single = 3E+38! * 10!`, and the `+`, `-` and `/` forms, in a project of their own) and refuses others with TB5002 (`^`, `CSng(1E+300)`, and `1E+300` assigned to a `Single`); which constants it refuses also changed with the other constants in the project, so that part is not in the reproducer. + +Severity: a silent wrong result. A `Single` overflow that ends the program or reaches a handler in VB6 gives `Inf` and carries on in twinBASIC. + + diff --git a/bugs/single-overflow-no-error/repro.json b/bugs/filed/single-overflow-no-error/repro.json similarity index 93% rename from bugs/single-overflow-no-error/repro.json rename to bugs/filed/single-overflow-no-error/repro.json index ae91f9abe..5f94084a1 100644 --- a/bugs/single-overflow-no-error/repro.json +++ b/bugs/filed/single-overflow-no-error/repro.json @@ -10,5 +10,6 @@ "^s \\+= x\\s+Err=0 s=1\\.#INF$", "^CSng\\(Double\\)\\s+Err=6 s=5$" ] - } + }, + "issue": 2476 } diff --git a/bugs/single-overflow-no-error/single-overflow-no-error.twinproj b/bugs/filed/single-overflow-no-error/single-overflow-no-error.twinproj similarity index 100% rename from bugs/single-overflow-no-error/single-overflow-no-error.twinproj rename to bugs/filed/single-overflow-no-error/single-overflow-no-error.twinproj diff --git a/bugs/single-overflow-no-error/src/Settings b/bugs/filed/single-overflow-no-error/src/Settings similarity index 100% rename from bugs/single-overflow-no-error/src/Settings rename to bugs/filed/single-overflow-no-error/src/Settings diff --git a/bugs/single-overflow-no-error/src/Sources/Startup.twin b/bugs/filed/single-overflow-no-error/src/Sources/Startup.twin similarity index 100% rename from bugs/single-overflow-no-error/src/Sources/Startup.twin rename to bugs/filed/single-overflow-no-error/src/Sources/Startup.twin diff --git a/bugs/single-overflow-no-error/vb6/Module1.bas b/bugs/filed/single-overflow-no-error/vb6/Module1.bas similarity index 100% rename from bugs/single-overflow-no-error/vb6/Module1.bas rename to bugs/filed/single-overflow-no-error/vb6/Module1.bas diff --git a/bugs/single-overflow-no-error/vb6/Probe.vbp b/bugs/filed/single-overflow-no-error/vb6/Probe.vbp similarity index 100% rename from bugs/single-overflow-no-error/vb6/Probe.vbp rename to bugs/filed/single-overflow-no-error/vb6/Probe.vbp diff --git a/bugs/filed/static-ctor-args/REPORT.md b/bugs/filed/static-ctor-args/REPORT.md new file mode 100644 index 000000000..499531282 --- /dev/null +++ b/bugs/filed/static-ctor-args/REPORT.md @@ -0,0 +1,46 @@ +Filed as [twinbasic/twinbasic#2492](https://github.com/twinbasic/twinbasic/issues/2492). + +## A `Static` in a Module procedure cannot be initialised with an argument constructor of a class declared later in the project + +**Describe the bug** +A `Static` variable in a Module procedure, initialised with `New` and a constructor that takes arguments, fails with TB5074 when the class is declared later in the project than the Module, in another file or further down the same file. With the class declared first, the same line compiles. `Dim` with the same initialiser compiles in both orders, so the declaration's position in the project changes whether a valid `Static` compiles. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `static-ctor-args.twinproj` (attached as `static-ctor-args.zip`). The project lists `Probe.twin`, which holds the Module, before `ProbeDog.twin`, which holds the class: + ``` + ' Probe.twin + Module Probe + Public Sub T() + Static s As Dog = New Dog("Rex") + Debug.Print s.GetName() + End Sub + End Module + + ' ProbeDog.twin + Private Class Dog + Private m_Name As String + Public Sub New(ByVal Name As String) + m_Name = Name + End Sub + Public Function GetName() As String + Return m_Name + End Function + End Class + ``` +2. See the project fail to compile with `TB5074 Could not bind to parameterized constructor of class 'Dog'. No compatible Sub New() method found`, at the `New`. +3. Move the `Dog` class into `Probe.twin`, above the Module: the project compiles with no error. + +What does not reproduce it, each measured with the class declared later than the Module unless it says otherwise: `Dim s As Dog = New Dog("Rex")` compiles, and so does `Static s As Dog` with `Set s = New Dog("Rex")`; a `Static` with a constructor that takes no arguments compiles; the same `Static` in a method of a Class compiles; and the same `Static` with the class declared first compiles, in a Module procedure, a Function, a Class method and a Property Get, in one file or two, with the class `Private` or `[COMCreatable(False)]`. The failure holds for a `Private` class and a public `[COMCreatable(False)]` one, and with a second constructor beside the one that takes arguments. + +**Expected behavior** +The declaration compiles whichever of the Module and the class comes first, as `Dim` does and as `Static` does when the class is first. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Also on BETA 983. Severity: a valid declaration does not compile, and which projects it affects depends on file order, so it appears and disappears as files are added. The workaround is a `Static` without an initialiser and a `Set` on first use, or declaring the class first. The order is the order of the files inside the `.twinproj`; the reproducer is packed with `Probe.twin` before `ProbeDog.twin`. + + diff --git a/bugs/filed/static-ctor-args/repro.json b/bugs/filed/static-ctor-args/repro.json new file mode 100644 index 000000000..a28ef850c --- /dev/null +++ b/bugs/filed/static-ctor-args/repro.json @@ -0,0 +1,9 @@ +{ + "mode": "compile", + "expect": { + "diagnostics": [ + "TB5074" + ] + }, + "issue": 2492 +} diff --git a/bugs/static-ctor-args/src/Settings b/bugs/filed/static-ctor-args/src/Settings similarity index 100% rename from bugs/static-ctor-args/src/Settings rename to bugs/filed/static-ctor-args/src/Settings diff --git a/bugs/static-ctor-args/src/Sources/Probe.twin b/bugs/filed/static-ctor-args/src/Sources/Probe.twin similarity index 100% rename from bugs/static-ctor-args/src/Sources/Probe.twin rename to bugs/filed/static-ctor-args/src/Sources/Probe.twin diff --git a/bugs/static-ctor-args/src/Sources/ProbeDog.twin b/bugs/filed/static-ctor-args/src/Sources/ProbeDog.twin similarity index 100% rename from bugs/static-ctor-args/src/Sources/ProbeDog.twin rename to bugs/filed/static-ctor-args/src/Sources/ProbeDog.twin diff --git a/bugs/static-ctor-args/src/Sources/Startup.twin b/bugs/filed/static-ctor-args/src/Sources/Startup.twin similarity index 100% rename from bugs/static-ctor-args/src/Sources/Startup.twin rename to bugs/filed/static-ctor-args/src/Sources/Startup.twin diff --git a/bugs/static-ctor-args/static-ctor-args.twinproj b/bugs/filed/static-ctor-args/static-ctor-args.twinproj similarity index 100% rename from bugs/static-ctor-args/static-ctor-args.twinproj rename to bugs/filed/static-ctor-args/static-ctor-args.twinproj diff --git a/bugs/filed/sub-named-like-module/REPORT.md b/bugs/filed/sub-named-like-module/REPORT.md new file mode 100644 index 000000000..2fde3ba4e --- /dev/null +++ b/bugs/filed/sub-named-like-module/REPORT.md @@ -0,0 +1,47 @@ +Filed as [twinbasic/twinbasic#2481](https://github.com/twinbasic/twinbasic/issues/2481). + +## A `[RunAfterBuild]` Sub does not run, and nothing says so, when its module holds a procedure named like the module + +**Describe the bug** +When a module holds a procedure with the same name as the module, a `[RunAfterBuild]` Sub in that module does not run, and nothing says so. The build succeeds and the exe is written. The DEBUG CONSOLE's last line is `[BUILD] Executing '..'...`, and nothing follows it: no message box, no entry in the error panel, no diagnostic. Not even the first statement of the Sub runs. The names are compared without regard to letter case, so `Runner` and `runner` clash. A Sub, a Function and a `Private` Sub all cause it, and the procedure does not have to be called. Observed in the DEBUG CONSOLE after pressing Build. + +This is a sibling of "A `[RunAfterBuild]` Sub does not run, and nothing says so, when a module is named like the project". There the module and the project share a name; here the project's name plays no part, and the clash is inside one module. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `sub-named-like-module.twinproj` (attached as `sub-named-like-module.zip`). The project is named `SubNamedLikeModule`. It has a `Startup` module with an empty `Sub Main`, and this module: + ``` + Module Probe + [RunAfterBuild] + Public Sub Hello() + Debug.Cls + Debug.Print "RunAfterBuild ran" + End Sub + + Public Sub Probe() + End Sub + End Module + ``` +2. Build the project. +3. Read the DEBUG CONSOLE. It ends at `[BUILD] Executing 'SubNamedLikeModule.Probe.Hello'...`. `RunAfterBuild ran` is never printed. +4. Rename `Sub Probe` to `NotProbe`, or move it to another module, and build again. The console is cleared and holds `RunAfterBuild ran`. + +**Expected behavior** +The Sub runs whatever the other procedures in its module are called, as it does once `Probe` is renamed. If a procedure may not have its module's name, the compiler should refuse it with an error, and not build the project and skip the Sub in silence. Writing `Probe.Probe` in code is already refused, with TB5027. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 983, identically, so this is not a recent regression. + +VB6 accepts the name. A project named `SubNamedLikeModule` with a standard module `Probe` (`Attribute VB_Name = "Probe"`) that holds `Public Sub Probe()` builds, and `Sub Main` calls `Probe` and it runs (the project is attached as `sub-named-like-module-vb6.zip`; it printed `Sub Probe ran, in a module named Probe`). VB6 has no `[RunAfterBuild]`, so what is checked is that the module and the Sub can coexist. + +What does not reproduce it: a procedure with another name, such as `NotProbe`. A procedure named like a module, when the module that holds it is a different one from the module that holds the `[RunAfterBuild]` Sub (a `Runner` module holding the Sub, and an `Other` module holding `Sub Other`). + +What reproduces it besides the plain case: a `Function Probe`, a `Private Sub Probe`, and the clash in another letter case (`Module Runner` with `Sub runner`). The failure needs no help from any tool: the Sub is the one marked `[RunAfterBuild]`, and its body never starts. + +Severity: moderate. A `[RunAfterBuild]` Sub runs after the exe is built, for example to sign it, and here it is skipped while the build reports success. Nothing points at the clash as the cause, so the project's owner has to find it by renaming procedures. + + diff --git a/bugs/sub-named-like-module/repro.json b/bugs/filed/sub-named-like-module/repro.json similarity index 91% rename from bugs/sub-named-like-module/repro.json rename to bugs/filed/sub-named-like-module/repro.json index 4f1682ddb..4fe961d43 100644 --- a/bugs/sub-named-like-module/repro.json +++ b/bugs/filed/sub-named-like-module/repro.json @@ -9,5 +9,6 @@ "absent": [ "^RunAfterBuild ran$" ] - } + }, + "issue": 2481 } diff --git a/bugs/sub-named-like-module/src/Settings b/bugs/filed/sub-named-like-module/src/Settings similarity index 100% rename from bugs/sub-named-like-module/src/Settings rename to bugs/filed/sub-named-like-module/src/Settings diff --git a/bugs/sub-named-like-module/src/Sources/Probe.twin b/bugs/filed/sub-named-like-module/src/Sources/Probe.twin similarity index 100% rename from bugs/sub-named-like-module/src/Sources/Probe.twin rename to bugs/filed/sub-named-like-module/src/Sources/Probe.twin diff --git a/bugs/sub-named-like-module/src/Sources/Startup.twin b/bugs/filed/sub-named-like-module/src/Sources/Startup.twin similarity index 100% rename from bugs/sub-named-like-module/src/Sources/Startup.twin rename to bugs/filed/sub-named-like-module/src/Sources/Startup.twin diff --git a/bugs/sub-named-like-module/sub-named-like-module.twinproj b/bugs/filed/sub-named-like-module/sub-named-like-module.twinproj similarity index 100% rename from bugs/sub-named-like-module/sub-named-like-module.twinproj rename to bugs/filed/sub-named-like-module/sub-named-like-module.twinproj diff --git a/bugs/sub-named-like-module/vb6/Module1.bas b/bugs/filed/sub-named-like-module/vb6/Module1.bas similarity index 100% rename from bugs/sub-named-like-module/vb6/Module1.bas rename to bugs/filed/sub-named-like-module/vb6/Module1.bas diff --git a/bugs/sub-named-like-module/vb6/Probe.vbp b/bugs/filed/sub-named-like-module/vb6/Probe.vbp similarity index 100% rename from bugs/sub-named-like-module/vb6/Probe.vbp rename to bugs/filed/sub-named-like-module/vb6/Probe.vbp diff --git a/bugs/filed/system-colour-extra-bytes-black/REPORT.md b/bugs/filed/system-colour-extra-bytes-black/REPORT.md new file mode 100644 index 000000000..8b6cb17ec --- /dev/null +++ b/bugs/filed/system-colour-extra-bytes-black/REPORT.md @@ -0,0 +1,40 @@ +Filed as [twinbasic/twinbasic#2495](https://github.com/twinbasic/twinbasic/issues/2495). + +## A system colour value with a nonzero second or third byte draws black since BETA 984, where VB6 takes the index from the low 16 bits + +**Describe the bug** +Since BETA 984 (release note: "fixed: Circle/Line/PSet color value handling to match VB6's relaxed rules"), `Line`, `Circle` and `PSet` draw black for a colour whose top byte is `&H80` and whose second or third byte is not 0, such as `&H80FF000F` or `&H80010003`. The colour is read as a system colour only when the value minus `&H80000000` is 0 to 30, and any other `&H80` value draws black. VB6 takes the system colour index from the low 16 bits and ignores the bits in between, so it draws system colour 15 for `&H80FF000F` and system colour 3 for `&H80010003`. BETA 983 gave the same colours as VB6 for these two values. Observed by reading the pixel back with `GetPixel` from a form with `AutoRedraw` set. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `system-colour-extra-bytes-black.twinproj` (attached as `system-colour-extra-bytes-black.zip`). Its `Sub Main` loads `Form1`, sets `AutoRedraw`, `ScaleMode = vbPixels` and `BackColor = &H30201`, draws one point with `Form1.PSet (10, 10), Color` for each of three colours, and prints the pixel read back with `GetPixel`. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + &H8000000F -> F0F0F0 + &H80FF000F -> 000000 + &H80010003 -> 000000 + ``` + +**Expected behavior** +The colours VB6 draws, which BETA 983 drew as well: +``` +&H8000000F -> F0F0F0 +&H80FF000F -> F0F0F0 +&H80010003 -> DBCDBF +``` +`F0F0F0` is system colour 15 (`vbButtonFace`) and `DBCDBF` is system colour 3 (`vbInactiveTitleBar`) on the machine of the observation. The VB6 project, attached as `system-colour-extra-bytes-black-vb6.zip`, prints exactly these three lines. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low. A value like these is not what a `vbButtonFace`-style constant holds, and the colours of VB6 forms and controls are always the plain `&H8000000F` form. The effect is a different colour for a value that VB6 accepts. + +BETA 983 drew the same colours as VB6 for `&H80FF000F` and `&H80010003`, by passing the value to `OleTranslateColor`. It also raised error 5 for a value that is neither a system colour nor an RGB value with a top byte of 0, 1 or 2, and 984 fixed that: `-1`, `&HFF0000FF`, `&H7FFFFFFF` and the `&H01` palette forms now draw as VB6 draws them (the low 24 bits), as do a `Double` such as 255.7 (256) and a `String` such as `"255"`. Only the `&H80` top byte differs from VB6. + +The same difference exists for the high end of the index range: VB6 draws `&H80000019` to `&H8000001E` black, and twinBASIC draws the system colour of that index (`&H8000001D` gives `FF9933`, `&H8000001E` gives `F0F0F0`); this looks deliberate, since the source names `COLOR_MENUBAR` (30) as the limit. `&H80000100` draws black in both BETA 995 and VB6, and `C8C8C8` (index 0) in BETA 983. + +The change is `TranslateColor2` in the VB package's `Graphics.twin`, which `Line`, `Circle` and `PSet` call on `Color`: for a top byte of `&H80` it returns the system colour when the value minus `&H80000000` is 0 to 30 and 0 otherwise, and for any other value it returns the low 24 bits. + + diff --git a/bugs/system-colour-extra-bytes-black/repro.json b/bugs/filed/system-colour-extra-bytes-black/repro.json similarity index 88% rename from bugs/system-colour-extra-bytes-black/repro.json rename to bugs/filed/system-colour-extra-bytes-black/repro.json index 8963f22ec..433e76025 100644 --- a/bugs/system-colour-extra-bytes-black/repro.json +++ b/bugs/filed/system-colour-extra-bytes-black/repro.json @@ -7,5 +7,6 @@ "^&H80FF000F -> 000000$", "^&H80010003 -> 000000$" ] - } + }, + "issue": 2495 } diff --git a/bugs/system-colour-extra-bytes-black/src/Settings b/bugs/filed/system-colour-extra-bytes-black/src/Settings similarity index 100% rename from bugs/system-colour-extra-bytes-black/src/Settings rename to bugs/filed/system-colour-extra-bytes-black/src/Settings diff --git a/bugs/system-colour-extra-bytes-black/src/Sources/Form1.tbform b/bugs/filed/system-colour-extra-bytes-black/src/Sources/Form1.tbform similarity index 100% rename from bugs/system-colour-extra-bytes-black/src/Sources/Form1.tbform rename to bugs/filed/system-colour-extra-bytes-black/src/Sources/Form1.tbform diff --git a/bugs/system-colour-extra-bytes-black/src/Sources/Form1.twin b/bugs/filed/system-colour-extra-bytes-black/src/Sources/Form1.twin similarity index 100% rename from bugs/system-colour-extra-bytes-black/src/Sources/Form1.twin rename to bugs/filed/system-colour-extra-bytes-black/src/Sources/Form1.twin diff --git a/bugs/system-colour-extra-bytes-black/src/Sources/Startup.twin b/bugs/filed/system-colour-extra-bytes-black/src/Sources/Startup.twin similarity index 100% rename from bugs/system-colour-extra-bytes-black/src/Sources/Startup.twin rename to bugs/filed/system-colour-extra-bytes-black/src/Sources/Startup.twin diff --git a/bugs/system-colour-extra-bytes-black/system-colour-extra-bytes-black.twinproj b/bugs/filed/system-colour-extra-bytes-black/system-colour-extra-bytes-black.twinproj similarity index 100% rename from bugs/system-colour-extra-bytes-black/system-colour-extra-bytes-black.twinproj rename to bugs/filed/system-colour-extra-bytes-black/system-colour-extra-bytes-black.twinproj diff --git a/bugs/system-colour-extra-bytes-black/vb6/Form1.frm b/bugs/filed/system-colour-extra-bytes-black/vb6/Form1.frm similarity index 100% rename from bugs/system-colour-extra-bytes-black/vb6/Form1.frm rename to bugs/filed/system-colour-extra-bytes-black/vb6/Form1.frm diff --git a/bugs/system-colour-extra-bytes-black/vb6/Module1.bas b/bugs/filed/system-colour-extra-bytes-black/vb6/Module1.bas similarity index 100% rename from bugs/system-colour-extra-bytes-black/vb6/Module1.bas rename to bugs/filed/system-colour-extra-bytes-black/vb6/Module1.bas diff --git a/bugs/system-colour-extra-bytes-black/vb6/Probe.vbp b/bugs/filed/system-colour-extra-bytes-black/vb6/Probe.vbp similarity index 100% rename from bugs/system-colour-extra-bytes-black/vb6/Probe.vbp rename to bugs/filed/system-colour-extra-bytes-black/vb6/Probe.vbp diff --git a/bugs/filed/textbox-text-ignores-maxlength/REPORT.md b/bugs/filed/textbox-text-ignores-maxlength/REPORT.md new file mode 100644 index 000000000..d01eaf1b0 --- /dev/null +++ b/bugs/filed/textbox-text-ignores-maxlength/REPORT.md @@ -0,0 +1,34 @@ +Filed as [twinbasic/twinbasic#2483](https://github.com/twinbasic/twinbasic/issues/2483). + +## `TextBox.Text` assigned in code is not limited by `MaxLength`, where VB6 truncates it + +**Describe the bug** +With `MaxLength` set to 3, `Text1.Text = "abcdef"` stores all six characters and `Len(Text1.Text)` is 6. VB6 truncates the new text to `MaxLength` characters: `Text` reads `abc`. `SelText` is affected in the same way: with `Text` equal to `"ab"` and the caret at the end, assigning `SelText = "cdef"` leaves `abcdef` in twinBASIC and `abc` in VB6. The `Text` property setter of the VB package sends `WM_SETTEXT` to the edit control, and the whole string is stored. Observed by reading `Text` back. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `textbox-text-ignores-maxlength.twinproj` (attached as `textbox-text-ignores-maxlength.zip`). Its `Form1` holds one `TextBox`, `Text1`, and its `Sub Main` loads the form, sets `Text1.MaxLength = 3`, assigns `Text` and `SelText`, and prints what `Text` holds. +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + Text = "abcdef": [abcdef] Len 6 + SelText = "cdef" after "ab": [abcdef] Len 6 + ``` + +**Expected behavior** +The text is cut to `MaxLength` characters, as in VB6 (the VB6 project, attached as `textbox-text-ignores-maxlength-vb6.zip`, prints): +``` +Text = "abcdef": [abc] Len 3 +SelText = "cdef" after "ab": [abc] Len 3 +``` +A program that relies on `MaxLength` to bound a field, for instance one that stores `Text` in a fixed-size record, gets a string longer than the limit. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Severity: low to medium. Typing into the box was not tried; the gap is the assignment in code. + +What was tried: BETA 983 prints the same two lines. Assigning the same long text a second time raises no `Change` event, in twinBASIC and in VB6. + + diff --git a/bugs/textbox-text-ignores-maxlength/repro.json b/bugs/filed/textbox-text-ignores-maxlength/repro.json similarity index 89% rename from bugs/textbox-text-ignores-maxlength/repro.json rename to bugs/filed/textbox-text-ignores-maxlength/repro.json index 0de565df1..359ffd01b 100644 --- a/bugs/textbox-text-ignores-maxlength/repro.json +++ b/bugs/filed/textbox-text-ignores-maxlength/repro.json @@ -6,5 +6,6 @@ "^Text = \"abcdef\": \\[abcdef\\] Len 6$", "^SelText = \"cdef\" after \"ab\": \\[abcdef\\] Len 6$" ] - } + }, + "issue": 2483 } diff --git a/bugs/textbox-text-ignores-maxlength/src/Settings b/bugs/filed/textbox-text-ignores-maxlength/src/Settings similarity index 100% rename from bugs/textbox-text-ignores-maxlength/src/Settings rename to bugs/filed/textbox-text-ignores-maxlength/src/Settings diff --git a/bugs/textbox-text-ignores-maxlength/src/Sources/Form1.tbform b/bugs/filed/textbox-text-ignores-maxlength/src/Sources/Form1.tbform similarity index 100% rename from bugs/textbox-text-ignores-maxlength/src/Sources/Form1.tbform rename to bugs/filed/textbox-text-ignores-maxlength/src/Sources/Form1.tbform diff --git a/bugs/textbox-text-ignores-maxlength/src/Sources/Form1.twin b/bugs/filed/textbox-text-ignores-maxlength/src/Sources/Form1.twin similarity index 100% rename from bugs/textbox-text-ignores-maxlength/src/Sources/Form1.twin rename to bugs/filed/textbox-text-ignores-maxlength/src/Sources/Form1.twin diff --git a/bugs/textbox-text-ignores-maxlength/src/Sources/Startup.twin b/bugs/filed/textbox-text-ignores-maxlength/src/Sources/Startup.twin similarity index 100% rename from bugs/textbox-text-ignores-maxlength/src/Sources/Startup.twin rename to bugs/filed/textbox-text-ignores-maxlength/src/Sources/Startup.twin diff --git a/bugs/textbox-text-ignores-maxlength/textbox-text-ignores-maxlength.twinproj b/bugs/filed/textbox-text-ignores-maxlength/textbox-text-ignores-maxlength.twinproj similarity index 100% rename from bugs/textbox-text-ignores-maxlength/textbox-text-ignores-maxlength.twinproj rename to bugs/filed/textbox-text-ignores-maxlength/textbox-text-ignores-maxlength.twinproj diff --git a/bugs/textbox-text-ignores-maxlength/vb6/Form1.frm b/bugs/filed/textbox-text-ignores-maxlength/vb6/Form1.frm similarity index 100% rename from bugs/textbox-text-ignores-maxlength/vb6/Form1.frm rename to bugs/filed/textbox-text-ignores-maxlength/vb6/Form1.frm diff --git a/bugs/textbox-text-ignores-maxlength/vb6/Module1.bas b/bugs/filed/textbox-text-ignores-maxlength/vb6/Module1.bas similarity index 100% rename from bugs/textbox-text-ignores-maxlength/vb6/Module1.bas rename to bugs/filed/textbox-text-ignores-maxlength/vb6/Module1.bas diff --git a/bugs/textbox-text-ignores-maxlength/vb6/Probe.vbp b/bugs/filed/textbox-text-ignores-maxlength/vb6/Probe.vbp similarity index 100% rename from bugs/textbox-text-ignores-maxlength/vb6/Probe.vbp rename to bugs/filed/textbox-text-ignores-maxlength/vb6/Probe.vbp diff --git a/bugs/filed/two-calling-conventions-accepted/REPORT.md b/bugs/filed/two-calling-conventions-accepted/REPORT.md new file mode 100644 index 000000000..d57dd398b --- /dev/null +++ b/bugs/filed/two-calling-conventions-accepted/REPORT.md @@ -0,0 +1,46 @@ +Filed as [twinbasic/twinbasic#2480](https://github.com/twinbasic/twinbasic/issues/2480). + +## A declaration that names two calling conventions compiles without a diagnostic + +**Describe the bug** +An API declaration, a procedure, a delegate or an interface member can name its calling convention after its name, and the compiler accepts two conventions on one declaration, in either order, with no error or warning. The call then uses one of them. In every pair tried on a `Declare`, `CDecl` is dropped and the other keyword is used, whichever comes first. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `two-calling-conventions-accepted.twinproj` (attached as `two-calling-conventions-accepted.zip`). Its `Startup.twin` declares `RtlUlongByteSwap`, which `ntdll` exports as fastcall on a 32-bit build (its one argument in ECX), under several conventions: + ``` + Private Declare PtrSafe Function SwapFast FastCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + Private Declare PtrSafe Function SwapCdecl CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + Private Declare PtrSafe Function SwapFastCdecl FastCall CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + Private Declare PtrSafe Function SwapCdeclFast CDecl FastCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + Private Declare PtrSafe Function SwapThisCdecl ThisCall CDecl Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + Private Declare PtrSafe Function SwapCdeclThis CDecl ThisCall Lib "ntdll" Alias "RtlUlongByteSwap" (ByVal Source As Long) As Long + ``` + It also holds a delegate (`CDecl FastCall`), a procedure in a module (`CDecl ThisCall`) and an interface member (`ThisCall CDecl`), each naming two conventions. `Sub Main` prints the result of each `Declare` for `&H11223344`. +2. Compile it. The Problems panel reports 0 errors and 0 warnings. +3. Run it (win32). The DEBUG CONSOLE prints: + ``` + FastCall: 44332211 + FastCall CDecl: 44332211 + CDecl FastCall: 44332211 + ThisCall CDecl: 44332211 + CDecl ThisCall: 44332211 + CDecl: 1EEC6F00 + ``` + The last value changes from run to run: called as cdecl, the function swaps whatever ECX held. Every pair is called as fastcall or thiscall, which pass this one argument the same way. + +**Expected behavior** +A declaration has one calling convention, so the second keyword is refused with an error that names it. As it is, nothing says which of the two the call uses, and when one of them is `CDecl`, it is dropped without a word. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995, identically apart from the cdecl value. + +Only the `Declare` pairs are observed at run time. The delegate, the procedure and the interface member are only compiled, so which convention they use is not known. Not tried: `FastCall` with `ThisCall`, which this function cannot tell apart, and a 64-bit build, where the conventions have no effect. + +Severity: low. It takes a mistake to trigger it, but the mistake goes unreported, and a declaration meant as cdecl that names a second convention by accident calls the function the wrong way. + + diff --git a/bugs/two-calling-conventions-accepted/repro.json b/bugs/filed/two-calling-conventions-accepted/repro.json similarity index 91% rename from bugs/two-calling-conventions-accepted/repro.json rename to bugs/filed/two-calling-conventions-accepted/repro.json index 4f4c37de7..d5a3497e9 100644 --- a/bugs/two-calling-conventions-accepted/repro.json +++ b/bugs/filed/two-calling-conventions-accepted/repro.json @@ -8,5 +8,6 @@ "^ThisCall CDecl: +44332211$", "^CDecl ThisCall: +44332211$" ] - } + }, + "issue": 2480 } diff --git a/bugs/two-calling-conventions-accepted/src/Settings b/bugs/filed/two-calling-conventions-accepted/src/Settings similarity index 100% rename from bugs/two-calling-conventions-accepted/src/Settings rename to bugs/filed/two-calling-conventions-accepted/src/Settings diff --git a/bugs/two-calling-conventions-accepted/src/Sources/Startup.twin b/bugs/filed/two-calling-conventions-accepted/src/Sources/Startup.twin similarity index 100% rename from bugs/two-calling-conventions-accepted/src/Sources/Startup.twin rename to bugs/filed/two-calling-conventions-accepted/src/Sources/Startup.twin diff --git a/bugs/two-calling-conventions-accepted/two-calling-conventions-accepted.twinproj b/bugs/filed/two-calling-conventions-accepted/two-calling-conventions-accepted.twinproj similarity index 100% rename from bugs/two-calling-conventions-accepted/two-calling-conventions-accepted.twinproj rename to bugs/filed/two-calling-conventions-accepted/two-calling-conventions-accepted.twinproj diff --git a/bugs/filed/udt-watch-codegen-error/REPORT.md b/bugs/filed/udt-watch-codegen-error/REPORT.md new file mode 100644 index 000000000..983f937ec --- /dev/null +++ b/bugs/filed/udt-watch-codegen-error/REPORT.md @@ -0,0 +1,45 @@ +Filed as [twinbasic/twinbasic#2485](https://github.com/twinbasic/twinbasic/issues/2485). + +## A watch on a variable of a user-defined type fails with a codegen error, and the Debug Console reports a linker error at every stop + +**Describe the bug** +While the debugger is stopped, a watch on a variable of a user-defined type cannot be evaluated. **Watches** shows `(compile error: codegen error; check for compilation errors)`, of type `ERROR`, and the Debug Console prints `[LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1`. The linker error comes again at every later stop while the watch exists, and `? p` in the Debug Console fails the same way. **Variables** shows the same variable and its fields without trouble, and a watch on one of its fields works. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `udt-watch-codegen-error.twinproj` (attached as `udt-watch-codegen-error.zip`). `Startup.twin` declares a type, and `Main` passes the line marked `BREAK` twice: + ``` + Private Type Point + X As Long + Y As Long + End Type + + Public Sub Main() + Dim p As Point + Dim i As Long + p.X = 7 + For i = 1 To 2 + Debug.Print "pass " & i ' BREAK + Next + End Sub + ``` +2. Put a breakpoint on the line marked `BREAK` (F9) and press F5. The run stops there, and **Variables** shows `p` as `{user defined type, 8 bytes}`, with `X` and `Y`. +3. Add a watch on `p` (**Debug > Add Watch...**, or the plus sign in **Watches**). The watch shows `(compile error: codegen error; check for compilation errors)`, of type `ERROR`, and the Debug Console prints `[LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1`. +4. Press F5. The run stops at the breakpoint again, and the Debug Console prints the linker error again. +5. Type `? p` in the Debug Console and press Enter. It fails with the same error, and the Debug Console prints the same linker line. + +**Expected behavior** +The watch shows `p` as **Variables** does, `{user defined type, 8 bytes}` opening to its fields, as VBA's Watch window shows a variable of a user-defined type. An expression that cannot be shown, such as `? p`, which has no single value to print, is refused with a message about the expression. A linker error about generated code reads as though the project had failed to build, which it has not. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 995 + +**Additional context** +Also on BETA 987, identically. + +What does not reproduce it: a watch on a field, `p.X`, which shows `7`, of type `Variant [Long]`; and **Variables**, which shows `p` and its fields. That type suggests a watch is evaluated as a `Variant`, which cannot hold a value of a user-defined type. Seen with a `Private Type` declared in the module; other declarations of the type were not tried. + +Severity: a watch on a structure is unusable, and while it exists every stop adds a linker error to the Debug Console. + + diff --git a/bugs/udt-watch-codegen-error/repro.json b/bugs/filed/udt-watch-codegen-error/repro.json similarity index 95% rename from bugs/udt-watch-codegen-error/repro.json rename to bugs/filed/udt-watch-codegen-error/repro.json index 8e38054d3..8f35928ff 100644 --- a/bugs/udt-watch-codegen-error/repro.json +++ b/bugs/filed/udt-watch-codegen-error/repro.json @@ -6,5 +6,6 @@ "the next stop reports the linker error again", "? p in the DEBUG CONSOLE fails the same way" ], - "steps": "1. Open udt-watch-codegen-error.twinproj in the twinBASIC IDE, put a breakpoint on the line marked BREAK (F9) and press F5. The run stops there; VARIABLES shows p as {user defined type, 8 bytes}, with X and Y.\n2. Add a watch on p (Debug > Add Watch..., or the plus sign in WATCHES).\n3. Expected: the watch shows p as VARIABLES does. Actual: its value is (compile error: codegen error; check for compilation errors) and its type ERROR, and the DEBUG CONSOLE prints [LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1.\n4. Press F5. The run stops at the breakpoint again, on the loop's second pass, and the DEBUG CONSOLE prints the linker error again: it does so at every stop while the watch is there.\n5. Type ? p in the DEBUG CONSOLE and press Enter: the same error, and the same linker line.\nControl: a watch on p.X shows 7, of type Variant [Long].\nAsserted on BETA 995 by `ide-test.bat --only watches` (the project is test/ide/probes/watches)." + "steps": "1. Open udt-watch-codegen-error.twinproj in the twinBASIC IDE, put a breakpoint on the line marked BREAK (F9) and press F5. The run stops there; VARIABLES shows p as {user defined type, 8 bytes}, with X and Y.\n2. Add a watch on p (Debug > Add Watch..., or the plus sign in WATCHES).\n3. Expected: the watch shows p as VARIABLES does. Actual: its value is (compile error: codegen error; check for compilation errors) and its type ERROR, and the DEBUG CONSOLE prints [LINKER] compilation (codegen) error detected in 'Startup.{temp_procedure}' at line #1.\n4. Press F5. The run stops at the breakpoint again, on the loop's second pass, and the DEBUG CONSOLE prints the linker error again: it does so at every stop while the watch is there.\n5. Type ? p in the DEBUG CONSOLE and press Enter: the same error, and the same linker line.\nControl: a watch on p.X shows 7, of type Variant [Long].\nAsserted on BETA 995 by `ide-test.bat --only watches` (the project is test/ide/probes/watches).", + "issue": 2485 } diff --git a/bugs/udt-watch-codegen-error/src/Settings b/bugs/filed/udt-watch-codegen-error/src/Settings similarity index 100% rename from bugs/udt-watch-codegen-error/src/Settings rename to bugs/filed/udt-watch-codegen-error/src/Settings diff --git a/bugs/udt-watch-codegen-error/src/Sources/Startup.twin b/bugs/filed/udt-watch-codegen-error/src/Sources/Startup.twin similarity index 100% rename from bugs/udt-watch-codegen-error/src/Sources/Startup.twin rename to bugs/filed/udt-watch-codegen-error/src/Sources/Startup.twin diff --git a/bugs/udt-watch-codegen-error/udt-watch-codegen-error.twinproj b/bugs/filed/udt-watch-codegen-error/udt-watch-codegen-error.twinproj similarity index 100% rename from bugs/udt-watch-codegen-error/udt-watch-codegen-error.twinproj rename to bugs/filed/udt-watch-codegen-error/udt-watch-codegen-error.twinproj diff --git a/bugs/import-no-settings-silent/repro.json b/bugs/import-no-settings-silent/repro.json deleted file mode 100644 index bc0a5d4be..000000000 --- a/bugs/import-no-settings-silent/repro.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mode": "cli", - "cli": ["import", "{tmp}\\packed.twinproj", "{src}\\Sources\\"], - "expect": { - "exit": 0, - "output": ["IMPORTED: .*Startup\\.twin", "^\\.\\.\\. FAILED"], - "absent": ["ERROR"] - } -} diff --git a/bugs/static-ctor-args/repro.json b/bugs/static-ctor-args/repro.json deleted file mode 100644 index ac758e917..000000000 --- a/bugs/static-ctor-args/repro.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "mode": "compile", - "expect": { - "diagnostics": ["TB5074"] - } -} From 60553ef1cfe34b7a0a19fa0556c75dde1aeb96d6 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:13:47 +0200 Subject: [PATCH 02/33] MustBeQualified: a Declare and a property procedure take it as a procedure does, the attribute on one accessor covers the property --- docs/Reference/Attributes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/Reference/Attributes.md b/docs/Reference/Attributes.md index 55df09b9f..61deadc95 100644 --- a/docs/Reference/Attributes.md +++ b/docs/Reference/Attributes.md @@ -771,11 +771,12 @@ Applicable to: [**Module**](Module), and to [procedures](../Gloss#procedure), [* Requires a name to be written with its qualifier, so that names that would otherwise clash stay apart. **MustBeQualified** and **MustBeQualified(True)** are the same; **MustBeQualified(False)** has no effect. -- On a procedure, code outside its module must call it as *module*.*procedure*. A call without the module name is error **TB5079**, *Unrecognized symbol*. Inside its own module the procedure can still be called by its name alone. +- On a procedure or a **Declare** statement, code outside its module must call it as *module*.*procedure*. A call without the module name is error **TB5079**, *Unrecognized symbol*. Inside its own module the procedure can still be called by its name alone. +- On one of a property's procedures, it applies to the property's name, so outside the module every use of the property needs the module name: with the attribute on the **Property Let** alone, a bare read through the **Property Get** is also **TB5079**. - On an **Enum**, code outside its module must write each member as *enum*.*member*; inside its own module the member can still be written alone. The type name itself can still be used alone, as in `As` *enum*. - On a **Module**, the module name must itself be qualified by the project or package name: *project*.*module*.*member*. The module's members can still be used by their names alone. -The **Assert** package marks all three of its modules this way, which is why its members are called as `Assert.Strict.IsTrue` and never as `Strict.IsTrue`; see [Assert](../Packages/Assert/). The **VBRUN** package marks some of its enumerations. +The **Assert** package marks all three of its modules this way, and every member in them, which is why its members are called as `Assert.Strict.IsTrue`: `Strict.IsTrue` and a bare `IsTrue` are both **TB5079**; see [Assert](../Packages/Assert/). The **VBRUN** package marks some of its enumerations. ```tb check_build [MustBeQualified] From 7763630424f2ed299bcaf2d338371ab4f97b7e6f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:13:47 +0200 Subject: [PATCH 03/33] Two 997 defects with reproducers: a spurious first-conversion Overflow in IDE runs (WARNINGs on CLng, CInt) and WebView2 headers Clone restarting --- .../REPORT.md | 44 ++++++ ...irst-conversion-spurious-overflow.twinproj | Bin 0 -> 2824 bytes .../repro.json | 11 ++ .../src/Settings | 61 ++++++++ .../src/Sources/Startup.twin | 18 +++ .../vb6/Module1.bas | 22 +++ .../vb6/Probe.vbp | 7 + .../webview2-headers-clone-restarts/REPORT.md | 41 +++++ .../repro.json | 14 ++ .../src/Settings | 75 +++++++++ .../src/Sources/EnumProbe.twin | 48 ++++++ .../src/Sources/Form1.tbform | 146 ++++++++++++++++++ .../src/Sources/Form1.twin | 32 ++++ .../src/Sources/Startup.twin | 8 + .../webview2-headers-clone-restarts.twinproj | Bin 0 -> 9579 bytes .../WebView2/WebView2HeadersCollection.md | 3 + docs/Reference/Default/VBA/Conversion/CInt.md | 3 + docs/Reference/Default/VBA/Conversion/CLng.md | 3 + 18 files changed, 536 insertions(+) create mode 100644 bugs/filed/first-conversion-spurious-overflow/REPORT.md create mode 100644 bugs/filed/first-conversion-spurious-overflow/first-conversion-spurious-overflow.twinproj create mode 100644 bugs/filed/first-conversion-spurious-overflow/repro.json create mode 100644 bugs/filed/first-conversion-spurious-overflow/src/Settings create mode 100644 bugs/filed/first-conversion-spurious-overflow/src/Sources/Startup.twin create mode 100644 bugs/filed/first-conversion-spurious-overflow/vb6/Module1.bas create mode 100644 bugs/filed/first-conversion-spurious-overflow/vb6/Probe.vbp create mode 100644 bugs/filed/webview2-headers-clone-restarts/REPORT.md create mode 100644 bugs/filed/webview2-headers-clone-restarts/repro.json create mode 100644 bugs/filed/webview2-headers-clone-restarts/src/Settings create mode 100644 bugs/filed/webview2-headers-clone-restarts/src/Sources/EnumProbe.twin create mode 100644 bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.tbform create mode 100644 bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.twin create mode 100644 bugs/filed/webview2-headers-clone-restarts/src/Sources/Startup.twin create mode 100644 bugs/filed/webview2-headers-clone-restarts/webview2-headers-clone-restarts.twinproj diff --git a/bugs/filed/first-conversion-spurious-overflow/REPORT.md b/bugs/filed/first-conversion-spurious-overflow/REPORT.md new file mode 100644 index 000000000..fdf7011d7 --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/REPORT.md @@ -0,0 +1,44 @@ +Filed as [twinbasic/twinbasic#2497](https://github.com/twinbasic/twinbasic/issues/2497). + +## In an IDE run, the first conversion of a Single or Double to an integer raises error 6 when the code also converts a constant out of the Long range + +**Describe the bug** +When the code a run compiles holds a conversion of a constant that does not fit a `Long`, such as `CLng(1E+20)`, the first conversion of a `Single` or `Double` value to an integer that the run makes raises error 6 (*Overflow*), and its target is left at 0. The value converted is in range, and the conversion that overflows need never run: `If False Then t = CLng(1E+20)` is enough. Only the first conversion of the run fails; the same statement run again gives the right value. Observed in IDE runs (F5 and a `[RunAfterBuild]` procedure); the built exe is not affected. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `first-conversion-spurious-overflow.twinproj` (attached as `first-conversion-spurious-overflow.zip`). Its `Sub Main`: + ``` + Dim s As Single = 4.5 + Dim t As Long + On Error Resume Next + t = CLng(s) + Debug.Print "first CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + t = CLng(s) + Debug.Print "second CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + If False Then t = CLng(1E+20) ' never runs; without this line both lines print t=4 Err=0 + ``` +2. Run it (F5) and read the DEBUG CONSOLE: + ``` + first CLng(s): t=0 Err=6 + second CLng(s): t=4 Err=0 + ``` +3. Without `On Error Resume Next`, the run stops at the first `t = CLng(s)` with error 6. + +**Expected behavior** +`t=4 Err=0` on both lines, as the built exe of the same project prints, and as VB6 prints for the same statements (attached as `first-conversion-spurious-overflow-vb6.zip`). + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 995. Each of these, anywhere in code the run compiles, causes it: `CLng(1E+20)`, `CInt(1E+10)`, and `t = 1E+20` assigned to a `Long`, in the same procedure or in one it calls (even after the failing conversion, or behind `If False`). These do not: the same conversion in a `Private` procedure that nothing references, `CLng(1E+9)` (which fits), `CByte(300.5)`, and `1E+300 * 1E+300`. Every value that causes it lies outside the 32-bit integer range; `CByte(300.5)` does not. The first conversion fails whether it is `CLng` or the count of `ShiftRotateLeft`, of a `Single` or of a `Double`, and whether the value is 4.5 or 4. If a conversion that overflows at run time (`CLng(d)` with `d` holding 1E+20) runs first, it raises 6 as it should, and the conversions after it are correct. Compiled with LLVM, nothing fails. + +This looks like a floating-point exception flag set while the compiler evaluates the constant conversion, in the IDE's process, which is also the process the run executes in; the run's first checked conversion then reads the flag and reports an overflow. + +Severity: a silent wrong result under `On Error Resume Next` (0 for 4), and otherwise a spurious error 6 that stops the run, in the IDE only, so a program that works as an exe fails while being debugged. + + diff --git a/bugs/filed/first-conversion-spurious-overflow/first-conversion-spurious-overflow.twinproj b/bugs/filed/first-conversion-spurious-overflow/first-conversion-spurious-overflow.twinproj new file mode 100644 index 0000000000000000000000000000000000000000..1a5f0b786837f5fcb69f1c01907b2329906958f1 GIT binary patch literal 2824 zcmcIm>u%yk6eim%wE5Y8m3oAtZlcD>TtY&s{4mC>P(y-3np6s_m9YmrTQg(Lj5mp* zJVBqMFW0B&IkpL~8+Em6YYF3-bN|j|=KDXt`D?56L#b5ibES;ReY6`%lLP}v`kg7Nq>>kiEaEL^|LM9-zt4eRO2g?CN*=*MJ7ao z^!$OVmoO0E3mEh~P^s7l6ET<(C=fiM0&)gEjJcS{42;C@Qz_=4e6E=Wrj%6B-iIj* z{8S@H#I`FhAyG15VX7GTv6c|YIMgWmzFJ&s;|l{a#osJ*pinR7Y#(kT9z-A*W`sP{ zwe8AKhADbd1h!q-t^huxbQ<#j#_1G>J{P-t`8JVyd;uC9y^P@mTD5~svLQ0rU2UHW z@Uqwt|8&9NjNO?P)xZh3r((XV*A200I`5e)vSq&QpQf9)(? zOqp6~rad=i{@T{ZjcJBJhX0AN!3_GCG6t6s6X3UE@p~WHu=?Jzz|{OCx#T8h#PE@3-;kT8JPt>` ziAZqgA#X8SKTalHqD>|{52M`3nZIC1BH!5Z8iQyrI^xz%U`^{#zZS}mSW3`UF zL#uP(dX{6`uG9DWM^3A~37tT+N%T{)DKiYcy96f$H_v#Z*o-MA5LzVORLW&6wQ{*y zIBDBX9GOJ~ndJ#bPZ7g^a7Qb&$fE5)3sX7zGrpX&AL^ucMdwemeV?H9A#M} z47Dp43ATbNjybed6a&G<1A?6*8V)WC&z7j&xaY&s;MBW-k>md2yz*WHonl>QJaEB+ zt1ggggmlVdZtkHl0K0vu7J6?DvmEBNjU2?~1|1z5CkeT6c|ADGELcnH1=l)|SKVT> zfGZmbC|#s|31R{5d%H_$Hvqq2?>Nl~RMM1m>}v`Y{BtuQkXi4caA=PbIy!@h`DPi?1?+F4-$)?x(24-?7efD6oi@w-9pkiR;!^g3dLRrf#Y-x?N-g zhisLr5f0_GC7)vpGV0r#oX*8eu2yT>ZeCZiCavNH$?yfM;Wg}6!D)ss&zAg}dXM7N KdzHuRxcwUrbP-kn literal 0 HcmV?d00001 diff --git a/bugs/filed/first-conversion-spurious-overflow/repro.json b/bugs/filed/first-conversion-spurious-overflow/repro.json new file mode 100644 index 000000000..d64c4d38c --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/repro.json @@ -0,0 +1,11 @@ +{ + "mode": "run", + "expect": { + "exit": 0, + "output": [ + "^first CLng\\(s\\): t=0 Err=6$", + "^second CLng\\(s\\): t=4 Err=0$" + ] + }, + "issue": 2497 +} diff --git a/bugs/filed/first-conversion-spurious-overflow/src/Settings b/bugs/filed/first-conversion-spurious-overflow/src/Settings new file mode 100644 index 000000000..57fa5cbd4 --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/src/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "FirstConversionSpuriousOverflow", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: In an IDE run, the first conversion of a Single or Double to an integer raises error 6 when the code also converts a constant out of the Long range", + "project.exportPathIsV2": true, + "project.id": "{E81F33B6-DE3A-48E7-95CE-A00CAFEF8A46}", + "project.name": "FirstConversionSpuriousOverflow", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/filed/first-conversion-spurious-overflow/src/Sources/Startup.twin b/bugs/filed/first-conversion-spurious-overflow/src/Sources/Startup.twin new file mode 100644 index 000000000..33db431d9 --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/src/Sources/Startup.twin @@ -0,0 +1,18 @@ +' In an IDE run, the first conversion of a Single to a Long raises error 6, because the +' procedure also holds a conversion of a constant out of the Long range, which never runs. +Module Startup + + Public Sub Main() + Dim s As Single = 4.5 + Dim t As Long + On Error Resume Next + t = CLng(s) + Debug.Print "first CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + t = CLng(s) + Debug.Print "second CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + If False Then t = CLng(1E+20) ' never runs; without this line both lines print t=4 Err=0 + End Sub + +End Module diff --git a/bugs/filed/first-conversion-spurious-overflow/vb6/Module1.bas b/bugs/filed/first-conversion-spurious-overflow/vb6/Module1.bas new file mode 100644 index 000000000..a36e2fd82 --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/vb6/Module1.bas @@ -0,0 +1,22 @@ +Attribute VB_Name = "Module1" +Option Explicit + +' The VB6 side of first-conversion-spurious-overflow: the same statements as the +' twinBASIC project's Sub Main, writing to out.txt. +' Build and run it with: node scripts/bug_repro.mjs vb6 first-conversion-spurious-overflow + +Sub Main() + Dim s As Single + Dim t As Long + Open App.Path & "\out.txt" For Output As #9 + s = 4.5 + On Error Resume Next + t = CLng(s) + Print #9, "first CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + t = CLng(s) + Print #9, "second CLng(s): t=" & t & " Err=" & Err.Number + Err.Clear + If False Then t = CLng(1E+20) + Close #9 +End Sub diff --git a/bugs/filed/first-conversion-spurious-overflow/vb6/Probe.vbp b/bugs/filed/first-conversion-spurious-overflow/vb6/Probe.vbp new file mode 100644 index 000000000..4d210b0e8 --- /dev/null +++ b/bugs/filed/first-conversion-spurious-overflow/vb6/Probe.vbp @@ -0,0 +1,7 @@ +Type=Exe +Module=Module1; Module1.bas +Startup="Sub Main" +ExeName32="Probe.exe" +Name="Probe" +CompilationType=0 +OptimizationType=0 diff --git a/bugs/filed/webview2-headers-clone-restarts/REPORT.md b/bugs/filed/webview2-headers-clone-restarts/REPORT.md new file mode 100644 index 000000000..8cc72133e --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/REPORT.md @@ -0,0 +1,41 @@ +Filed as [twinbasic/twinbasic#2496](https://github.com/twinbasic/twinbasic/issues/2496). + +## The WebView2 headers enumerator's Clone starts at the first header, not at the enumerator's position + +**Describe the bug** +`IEnumVARIANT::Clone` on the enumerator of a `WebView2RequestHeaders` object (`_NewEnum`, a `WebView2HeadersCollection`) returns an enumerator that starts at the first header, whatever the position of the enumerator it was cloned from. After `Next` has returned the first header, the clone's `Next` returns the first header again; after `Skip 2`, the clone still starts at the first. Observed in a run of the reproducer project. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `webview2-headers-clone-restarts.twinproj` (attached as `webview2-headers-clone-restarts.zip`). Its form holds one WebView2 control, which navigates to `https://example.com/page` and answers that request itself in `WebResourceRequested`. Before answering, the handler passes `Request.Headers` to `EnumProbe.Run`, which declares `IEnumVARIANT` (the package's own declaration is private) and calls it on `Headers._NewEnum()`: + ``` + Dim u As stdole.IUnknown = Headers._NewEnum() + Dim e As IEnumVARIANTProbe = u + Debug.Print "enumerator Next: " & NextName(e) + Dim c As IEnumVARIANTProbe = e.Clone() + Debug.Print "clone Next: " & NextName(c) + Debug.Print "enumerator Next: " & NextName(e) + ``` +2. Run it (F5); the form closes by itself. The DEBUG CONSOLE shows: + ``` + For Each: Accept Upgrade-Insecure-Requests User-Agent sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform + enumerator Next: Accept + clone Next: Accept + enumerator Next: Upgrade-Insecure-Requests + after Skip 2, enumerator Next: User-Agent + after Skip 2, clone Next: Accept + ``` + +**Expected behavior** +`clone Next: Upgrade-Insecure-Requests` and `after Skip 2, clone Next: User-Agent`: `IEnumVARIANT::Clone` creates an enumerator with the same state as the current one, so the clone continues from the same position. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +In the package's `WebView2HeadersCollection.twin`, the four derived classes' `Clone` pass `IterationIdx` to a new enumerator, whose constructor `Skip`s that many headers, but nothing ever assigns `IterationIdx`: `Next` and `Skip` call `Iterator.MoveNext` without advancing it, so it stays 0. Incrementing it in `Next` (once per header fetched) and in `Skip`, and setting it to 0 in each `Reset`, would fix it. The response headers classes (`WebView2ResponseHeaders`) have the same `Clone`; only the request headers were run. + +Severity: low. `For Each` does not call `Clone`, so only a COM client that clones the enumerator gets the wrong headers. + + diff --git a/bugs/filed/webview2-headers-clone-restarts/repro.json b/bugs/filed/webview2-headers-clone-restarts/repro.json new file mode 100644 index 000000000..93366b989 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/repro.json @@ -0,0 +1,14 @@ +{ + "mode": "run", + "expect": { + "exit": 0, + "output": [ + "^enumerator Next: Accept$", + "^clone Next:\\s+Accept$", + "^enumerator Next: Upgrade-Insecure-Requests$", + "^after Skip 2, enumerator Next: User-Agent$", + "^after Skip 2, clone Next:\\s+Accept$" + ] + }, + "issue": 2496 +} diff --git a/bugs/filed/webview2-headers-clone-restarts/src/Settings b/bugs/filed/webview2-headers-clone-restarts/src/Settings new file mode 100644 index 000000000..9620769d0 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/src/Settings @@ -0,0 +1,75 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "Webview2HeadersCloneRestarts", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: The WebView2 headers enumerator's Clone starts at the first header, not at the enumerator's position", + "project.exportPathIsV2": true, + "project.id": "{956097AE-1AD4-441D-A9A3-E57848F08C59}", + "project.name": "Webview2HeadersCloneRestarts", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + }, + { + "id": "{5D19653F-F49E-41F2-ABF8-16E473A89A7C}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WebView2 Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WebView2Package", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 3 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/filed/webview2-headers-clone-restarts/src/Sources/EnumProbe.twin b/bugs/filed/webview2-headers-clone-restarts/src/Sources/EnumProbe.twin new file mode 100644 index 000000000..3265dd010 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/src/Sources/EnumProbe.twin @@ -0,0 +1,48 @@ +' IEnumVARIANT, declared here because the package's own declaration is private. +[InterfaceId("00020404-0000-0000-C000-000000000046")] +Interface IEnumVARIANTProbe Extends stdole.IUnknown + Type CArrayOfVariants + Elements(1) As Variant + End Type + + Sub Next(ByVal celt As Long, ByRef rgVar As CArrayOfVariants, ByRef pCeltFetched As Long) + Sub Skip(ByVal celt As Long) + Sub Reset() + Function Clone() As stdole.IUnknown +End Interface + +Module EnumProbe + + Private Function NextName(ByVal e As IEnumVARIANTProbe) As String + Dim arr As IEnumVARIANTProbe.CArrayOfVariants + Dim fetched As Long + e.Next 1, arr, fetched + If fetched = 0 Then Return "(end)" + Dim hd As WebView2Header = arr.Elements(0) + Return hd.Name + End Function + + Public Sub Run(ByVal Headers As WebView2RequestHeaders) + Dim h As WebView2Header + Dim names As String + For Each h In Headers + names &= " " & h.Name + Next + Debug.Print "For Each:" & names + + Dim u As stdole.IUnknown = Headers._NewEnum() + Dim e As IEnumVARIANTProbe = u + Debug.Print "enumerator Next: " & NextName(e) + Dim c As IEnumVARIANTProbe = e.Clone() + Debug.Print "clone Next: " & NextName(c) + Debug.Print "enumerator Next: " & NextName(e) + + Set u = Headers._NewEnum() + Set e = u + e.Skip 2 + Set c = e.Clone() + Debug.Print "after Skip 2, enumerator Next: " & NextName(e) + Debug.Print "after Skip 2, clone Next: " & NextName(c) + End Sub + +End Module diff --git a/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.tbform b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.tbform new file mode 100644 index 000000000..5610cc552 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.tbform @@ -0,0 +1,146 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": 13223074, + "BorderStyle": "vbSizable", + "Caption": "WebView2 form", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{7B247400-0000-4000-9000-7B2474000F01}", + "HasDC": true, + "Height": 480, + "HelpContextID": 0, + "Icon": "", + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": "", + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "Form1", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": "", + "PaletteMode": "vbPaletteModeHalftone", + "Picture": "", + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 8560, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 11760, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 640, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": true, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 3573907440, + "_children": [ + { + "AdditionalAllowedFrameAncestors": null, + "Anchors": { + "Bottom": true, + "Left": true, + "Right": true, + "Top": true, + "_clsid": "{8524D4B5-72A9-40A9-A189-29E7905C40CA}" + }, + "AreBrowserAcceleratorKeysEnabled": true, + "AreDefaultContextMenusEnabled": true, + "AreDefaultScriptDialogsEnabled": true, + "AreDevToolsEnabled": true, + "AreHostObjectsAllowed": true, + "BackColor": 10534293, + "CausesValidation": true, + "Dock": "vbDockNone", + "DocumentURL": "about:blank", + "DragIcon": "", + "DragMode": "vbManual", + "Enabled": true, + "EnvironmentOptions": { + "AdditionalBrowserArguments": null, + "AllowSingleSignOnUsingOSPrimaryAccount": false, + "BrowserExecutableFolder": null, + "EnableTrackingPrevention": true, + "ExclusiveUserDataFolderAccess": false, + "Language": null, + "TargetCompatibleBrowserVersion": "86.0.616.0", + "UserDataFolder": "", + "_className": "WebView2EnvironmentOptions", + "_clsid": "{185E63C0-E641-47ED-B6A9-8565168A7FCF}" + }, + "Height": 440, + "Index": -1, + "IsBuiltInErrorPageEnabled": true, + "IsGeneralAutoFillEnabled": true, + "IsMuted": false, + "IsPasswordAutoSaveEnabled": false, + "IsPinchZoomEnabled": true, + "IsScriptEnabled": true, + "IsStatusBarEnabled": false, + "IsSwipeNavigationEnabled": true, + "IsWebMessageEnabled": true, + "IsZoomControlEnabled": true, + "Left": 0, + "MouseIcon": "", + "MousePointer": "vbDefault", + "Name": "WebView21", + "TabIndex": 7, + "TabStop": true, + "Tag": null, + "Top": 0, + "UseDeferredEvents": true, + "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + "Visible": true, + "Width": 620, + "ZoomFactor": 1, + "_className": "WebView2", + "_clsid": "{93B7E07E-6C7F-4758-8E51-4B1B17EC0A62}" + } + ], + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.twin b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.twin new file mode 100644 index 000000000..d14bf80b9 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Form1.twin @@ -0,0 +1,32 @@ +' A form holding one WebView2 control. Once the control is ready it navigates to a +' page whose request it answers itself in WebResourceRequested, after enumerating the +' request's headers there; when that navigation completes the form closes. + +[FormDesignerId("7B247400-0000-4000-9000-7B2474000F01")] +[PredeclaredId] +Class Form1 + + Private Probed As Boolean + + Private Sub WebView21_Ready() Handles WebView21.Ready + WebView21.AddWebResourceRequestedFilter "*", wv2All + WebView21.Navigate "https://example.com/page" + End Sub + + Private Sub WebView21_WebResourceRequested(ByVal Request As WebView2Request, ByVal Response As WebView2Response) _ + Handles WebView21.WebResourceRequested + If Not Probed Then + Probed = True + EnumProbe.Run Request.Headers + End If + Response.StatusCode = 200 + Response.ReasonPhrase = "OK" + Response.ContentUTF8 = "probe" + End Sub + + Private Sub WebView21_NavigationComplete(ByVal IsSuccess As Boolean, ByVal WebErrorStatus As Long) _ + Handles WebView21.NavigationComplete + If Probed Then Me.Close + End Sub + +End Class diff --git a/bugs/filed/webview2-headers-clone-restarts/src/Sources/Startup.twin b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Startup.twin new file mode 100644 index 000000000..9d70f1799 --- /dev/null +++ b/bugs/filed/webview2-headers-clone-restarts/src/Sources/Startup.twin @@ -0,0 +1,8 @@ +Module Startup + + Public Sub Main() + Form1.Show vbModal + Debug.Print "Form1 closed" + End Sub + +End Module diff --git a/bugs/filed/webview2-headers-clone-restarts/webview2-headers-clone-restarts.twinproj b/bugs/filed/webview2-headers-clone-restarts/webview2-headers-clone-restarts.twinproj new file mode 100644 index 0000000000000000000000000000000000000000..dd63bce931d0889d7d1588d5b7bd8d3f252080df GIT binary patch literal 9579 zcmcIqTXWk;cGgTbn@CyjKILs6x=>R)@|H-0chWPnRUk;(EQ^v#q~)zxQ>rG=BC!Gi z1{Yn{(OdpWDnBHDC%+=!X>=0+NsnhMJ93#KPMsJ|856;W*aVIVF=obV`#F`WPW8ehLY_*Z<6SL-lJggbj$i&JkN@yHV;-hZT7 zUmv`{=fsO0;rl!gVOruLap3u9d|q(pjxWk0{wW(i#4l(Qrjb(~|2i8dw(0KZKl}>x zuMU2T&tZ_RE~0QQs>y>F$Z0f{eq8AAEgKWZm329`&Sodf6^_p%;j*QO1e*(or?Ftk zQm}QV<+m{l9|Fb46E6&y7qfNb-Sb3LUmSfL2Z@Moxg*Bzv0<8K-E5l83ci@xhpoR6 z$uyg7SpjyAJO`bfpu@G%jA8?!iZLtj+K?}NJlI=nby_#q=O+lnIobbfmUQ}7^( zvmqKD`eG$OIj+5AR?HMbHa-koIsL^^_B>7J>`Xi+$Ni@(?lagep*g2vusC7;r%Q3m zq6H)naeGD0u(mO4Bob!{4Atb9x%lawx85VZzyWKEI=59yx!kMW+b+eN}E07E1@(Jq;Sz4B>ZyGX%czCB8PS0tr(9a5<3&B+t`$+ z-Il@02St_iVYL&Ie8Lo8K0dzHH1-2C*=#8Sb}5oH3Yc*W2YG4ayrkb!8UXQqmB%+` zLU?uK6{Z53Tt&%Rx>dp?lSSBSxHd4RbKi3qdCO%QDC|`FAO!V_m*U@3BoZ}JSV$^Y z69FM2;t6(3`o5Ms z**L7&g3}?bc03W{MOB5)XO?j&->Vf!*YYw>ahW1cU)C;PZz>X?_R|m%k0_%ds+25P zy)4N28GGWl$iOU{eZs!1s@yj|N}f;_^7CM#XhB$z%h1;>54|{|v*xRV-{2GOwW?K< z`7M5zj-vni-NC`f7e|Mm@c+=T{0IINPnY4tJMlCRdF0xNB;z%^<$f$qGM@EX@Q4SF z0NuEsTk^T#8frLA!b{;s{GmLpk5kMJ{V)Q#)~MGTW~Z6W=!X#sfNApND~VI@uY8V& zY}n>&%EYvo4sb@>(8IpBw!@ph;G>qZ!}ZgJ2$U=uE9w#Y zp57J>eem35N$O{c4Fl5BElD4FzOQ#_W^6<%2pvsl054wStPc{k?g;w#L!U$yfF|lV z;YfMAbV`(<8dI?d1$#d(2m*RE)K#lP2qm&Rku0NhS5Q%zB6_s=`v7+Fy@14(^ooSu zW2IhecADKryPj_kotax=wvXqm)^FqCj7CVCW%xbS02=j{%S>#v>$1T7wntr1v^u{V0$WR zTd}qA3DGV>oGOB14G}sJw>!(g=)3VMlSN{ zIGFMHZqCUhxqnRQw)1{{5yqa*arbjYoH~)%WTqLPk3uJnEo9fJ@P$J@T-ef#)8=z0 zE$M}aNf>X>*kmE+39*9N6_wYnyx61kTU7MJk|*(O>BSpQEiAqnq{uAmrO{|N6~-UD zzzvaP;a1>;bDkk#a9?41b2A z95nl_N~do1Doqm~R;}Bs)O*8D&urOE)3%;*Ex?8p9;9R>`ce20i^y_t$IFUL_+Gr{ zB+b}W<|3;h+si!kC0>M$w@wBg_rt|c3BI3&s2Dyg?JWZ4eEt^%TCDId17wuy8MD=B z)_aZ2p$-k3?(Q+JvL1||)h-1fjR9N{(kL_ITb?qpXAn?*odrpykeKAFx zo(Jz^Je*H)v03rx38oHHR63==#R5v~@KHEvLg)(1x9f?sRhQKdL{kV+>y^xc6J ze2dpvDf>CLNs7kY7SLGb0=s`efiDj(eqUInXc1!ckiz~g3|F69APelzj^ac~<35ja zhOGsrIJ?CezxNh0CUd@GXpUo;Xabk{2|!3mb&l*8QYs;UwXDc?LiP)F8s=?#O-;L% zfpS2bw4;*_XwyWNBgGAE6$X%6EDi7o=S7wk5n7K!immd|mpQk!zySI6B>XE}fWK;0 zO?I3e{eL^^~Ez$j3Ij-&3GqBl^T|*Hs;GXpp5w+`)MX<+JJr zjQc}!BfSuRy+*$?G&{pe+wP1If30q%J8U5m`?Y?pGqg>sU03ns4wsdsOLnOdFkA%QDTjIcW46QZxjkOPk%~>6fg~cU#K;gU0i> z%#t@_wnQpLwq(>{(J6=FdPJAxDmzCFAsfqzLT%&-P539~C1^d#06_;V2^oKJ^ex)Y z3&9?iA-d5~)>tDxTwx!O74bC|{w?zYT1Yp{RRd1A=qSpTh;D_DQfO(DSS1Tx?Pc}? zfCeJ*9hO3SF+shlbwTEZ@+?&~;k_f1x*O)z7bByplox7+T6UadoGYO8|{;^Qn3}Rl2sMv5oE# znt^rG+`|If#$j-=j5xNBKJfXwLUVj0NEWoH@@AthF@3*GR{o#9pNH<#pVqXm?_bI9 z-@lRre~aDn1CFW~3K?a!aXd|BF+@gD4y^43z-1Yb?T;Af)yvPY)b46IB(PJ#MrT}Y zP^tGS8MDmd?xMciTBVnW=pZM^YjLry`m6uI$fu)211a{_Tcl{GBa2nN04WX`la5k# zwLv!lY%n@qHL85Qo_R^u2Zd2S`+l@EX^uk9z2LGnX?*i3dn-xL&)3&fGPv+Kn0ts9vi#Xj;uqy{iH7kf~Q|I5w^~@;!eJKf&cW z1Uv0$GM*{TG-@C1^U1~dba=@wEc>1HX80qcm-u~aI<{GbUG*7>raPkNqsPhA0_>O) zNc^&ZSq5`~kb$TPl(S+a1yW=7VSFZ6sOZ%dR}sjoz9K5PWPoF~GP?#*LAyoO(3Swd zM3Y?IZX30|pxd=xebjICD($9iRholdztV4ZIu)zcGV7yecQ~@`UmkSaDA>Xe=kx~x zvPcVMdqyL$CJvSe+&Gz63~+`!N1?-TZ3A!-D->~qh@bdY1T&d ziq#)=E4B8p*=bnap4It<&_|Y&Z}7Ey7m)pXyR}|QM`{VUzpsyziyz^tRyYsvhad6? zzuNS9$a0%gSsqO3I%@|~q*9?zH3k8);d [!WARNING] +> In BETA 997, **Clone** returns an enumerator that starts at the first header, not at the position of the enumerator it copies: after **Next** has returned the first header, the clone's **Next** returns the first header again, and after **Skip** `2` the clone still starts at the first. `For Each` never calls **Clone**, so only a COM client that does is affected. + A **WebView2HeadersCollection** is never created by application code. The package returns one of the four derived classes listed under [Derived classes](#derived-classes), typed as **WebView2HeadersCollection**. ```tb check_build diff --git a/docs/Reference/Default/VBA/Conversion/CInt.md b/docs/Reference/Default/VBA/Conversion/CInt.md index 679d73071..2eebbeb71 100644 --- a/docs/Reference/Default/VBA/Conversion/CInt.md +++ b/docs/Reference/Default/VBA/Conversion/CInt.md @@ -22,6 +22,9 @@ When the fractional part is exactly `0.5`, **CInt** always rounds it to the near **CInt** is the internationally aware alternative to [**Val**](Val) for converting a string to a numeric type. +> [!WARNING] +> In BETA 997, in a run from the IDE, a conversion of a constant outside the range of a **Long**, such as `CInt(1E+10)`, makes the run's first conversion of a **Single** or **Double** value to an integer raise error 6, *Overflow*, even for a value in range, and leaves its target at 0. The constant conversion has this effect wherever it appears in code the run compiles, even in a statement that never runs, such as `If False Then i = CInt(1E+10)`. Only the first such conversion of the run fails, and under **On Error Resume Next** its 0 goes unnoticed. A built executable, and code compiled with LLVM, are not affected. VB6 converts the value correctly. + ### Example This example uses the **CInt** function to convert a value to an **Integer**. diff --git a/docs/Reference/Default/VBA/Conversion/CLng.md b/docs/Reference/Default/VBA/Conversion/CLng.md index 9a2c9b75a..178e4b77a 100644 --- a/docs/Reference/Default/VBA/Conversion/CLng.md +++ b/docs/Reference/Default/VBA/Conversion/CLng.md @@ -20,6 +20,9 @@ The return type is **Long**. If *expression* is outside the range of a **Long**, When the fractional part is exactly `0.5`, **CLng** always rounds it to the nearest even number. For example, `0.5` rounds to `0`, and `1.5` rounds to `2`. **CLng** differs from the [**Fix**](Fix) and [**Int**](Int) functions, which truncate, rather than round, the fractional part of a number. +> [!WARNING] +> In BETA 997, in a run from the IDE, a conversion of a constant outside the range of a **Long**, such as `CLng(1E+20)`, makes the run's first conversion of a **Single** or **Double** value to an integer raise error 6, *Overflow*, even for a value in range, and leaves its target at 0. The constant conversion has this effect wherever it appears in code the run compiles, even in a statement that never runs, such as `If False Then t = CLng(1E+20)`. Only the first such conversion of the run fails, and under **On Error Resume Next** its 0 goes unnoticed. A built executable, and code compiled with LLVM, are not affected. VB6 converts the value correctly. + ### Example This example uses the **CLng** function to convert values to a **Long**. From 337e62ab43f72e7b2e874b3c06b7536f96c4993c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:35:03 +0200 Subject: [PATCH 04/33] waitForCompile reports Safe Mode; the packages lane judges its compile by compileOutcome --- scripts/lib/tb-ide.mjs | 30 +++++++++++++++++++++++++----- test/ide/packages.test.mjs | 7 ++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/scripts/lib/tb-ide.mjs b/scripts/lib/tb-ide.mjs index 0dd7a2f64..384349ab8 100644 --- a/scripts/lib/tb-ide.mjs +++ b/scripts/lib/tb-ide.mjs @@ -558,6 +558,11 @@ const BUILD_STATE_JS = `JSON.stringify({ h: document.getElementById("hintCount")?.textContent ?? "", i: document.getElementById("infoCount")?.textContent ?? "", p: typeof projectFilePath !== "undefined" ? projectFilePath : null, + // Safe Mode: the IDE sets the build box to nocompile itself after four + // compiler restarts in a minute, and the compiler it then starts compiles + // nothing, so the status and the counts stand still as a settled compile does. + safe: typeof buildConfigSelector !== "undefined" && !!buildConfigSelector && + buildConfigSelector.value === "nocompile", // The files waitForCompile waits for c.wire to see: every .twin file outside // References (1) and Packages (7), which the compiler reports none for. src: (() => { @@ -711,9 +716,9 @@ const EARLY_QUIET_MS = 300; * given: compared as typed, a relative path never matched, and the IDE was * never reported open. * @param {number} o.timeout milliseconds - * @returns {Promise<{loaded: boolean, crash: object | null, drops: number, last: string | null, - * blocked: boolean, early: boolean}>} - * `last` is the final sample, as the JSON string readBuildState returned; + * @returns {Promise<{loaded: boolean, crash: object | null, safe: boolean, drops: number, + * last: string | null, blocked: boolean, early: boolean}>} + * `safe` is true when the IDE had fallen into Safe Mode; `last` is the final sample, as the JSON string readBuildState returned; * `blocked` is attachIde's `pageBlocked`; `early` is true when `c.wire` saw * the compile end and the sample agreed with it, and false when the sample * stood still for five seconds instead @@ -729,6 +734,7 @@ export async function waitForCompile(c, { project, timeout }) { seenUp = false, drops = 0, crash = null, + safe = false, agreed = null, early = false; // Samples come once a second, as they always have, and only those count a @@ -761,6 +767,11 @@ export async function waitForCompile(c, { project, timeout }) { crash = await awaitCrashName(c, v.crash); break; } + if (v.safe) { + safe = true; + last = s; + break; + } const up = v.st === "tB Services: OPERATIONAL"; if (up) seenUp = true; else if (seenUp && scheduled && ++drops >= 2) break; @@ -791,7 +802,7 @@ export async function waitForCompile(c, { project, timeout }) { else if (now - stableSince >= 5000) break; last = s; } - return { loaded, crash, drops, last, blocked: !!c.pageBlocked, early }; + return { loaded, crash, safe, drops, last, blocked: !!c.pageBlocked, early }; } /** @@ -807,7 +818,7 @@ export async function waitForCompile(c, { project, timeout }) { * as the array `message` prints on its `last parsing:` line, the files the * compiler died parsing. */ -export function compileOutcome({ loaded, crash, drops, last, blocked }, { name }) { +export function compileOutcome({ loaded, crash, safe, drops, last, blocked }, { name }) { // A crash is reported by the file the compiler died parsing, because in a batch // of generated probes that name is the whole answer: it says which sample to // take out, and a caller bisecting the batch has somewhere to start. @@ -822,6 +833,15 @@ export function compileOutcome({ loaded, crash, drops, last, blocked }, { name } crashFiles: crash.files ?? [], }; } + if (safe) { + return { + ok: false, + code: 4, + message: + "the compiler crashed in a loop and the IDE fell into Safe Mode -- this project takes it down" + + "\n(read the IDE's DEBUG CONSOLE with --keep for the exception detail)", + }; + } if (drops >= 2) { return { ok: false, diff --git a/test/ide/packages.test.mjs b/test/ide/packages.test.mjs index 9f1c4eac6..15db42891 100644 --- a/test/ide/packages.test.mjs +++ b/test/ide/packages.test.mjs @@ -25,7 +25,7 @@ import { cpSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { before, test } from "node:test"; import { fileURLToPath } from "node:url"; -import { waitForCompile } from "../../scripts/lib/tb-ide.mjs"; +import { compileOutcome, waitForCompile } from "../../scripts/lib/tb-ide.mjs"; import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs"; import { click, clickAt, openFile, waitFor } from "../../scripts/lib/tb-operate.mjs"; import { packTree } from "../../scripts/lib/tb-project.mjs"; @@ -305,8 +305,9 @@ scenario("Replacing an embedded package", (lane) => { // the package unticked, Main no longer compiles. async function settle({ errors = false } = {}) { const waited = await waitForCompile(c, { project: lane.project, timeout: 90 * 1000 }); - assert.ok(waited.loaded && !waited.crash, `the compiler did not settle: ${JSON.stringify(waited)}`); - if (!errors) assert.match(waited.last, /"e":"0"/, `the compile has errors: ${waited.last}`); + const outcome = compileOutcome(waited, { name: lane.project }); + assert.ok(outcome.ok, `the compiler did not settle: ${outcome.message}`); + if (!errors) assert.equal(outcome.counts[0], 0, `the compile has errors: ${outcome.rows.join("\n")}`); } // Start a run from the editor, as F5 does, and read the version the host From 1388efa10458983bde038f50062ee6fa7984391f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:47:35 +0200 Subject: [PATCH 05/33] tbdocs strips a trailing slash from url and normalises baseurl, so no URL gets // --- builder/tbdocs.mjs | 4 ++++ builder/url.mjs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 8e16e9cd3..11b65c5bb 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -182,6 +182,10 @@ const TASKS = { const config = yaml.load(text); if (ctx.opts.baseurl != null) config.baseurl = ctx.opts.baseurl; if (ctx.opts.url != null) config.url = ctx.opts.url; + // Every consumer joins these with a path that starts with "/", so a + // trailing slash or a missing leading one would double or lose a separator. + config.baseurl = normalizeBaseurl(config.baseurl); + config.url = String(config.url ?? "").replace(/\/+$/, ""); return { config }; }, submit() {}, diff --git a/builder/url.mjs b/builder/url.mjs index a5ec09332..42b2eb535 100644 --- a/builder/url.mjs +++ b/builder/url.mjs @@ -53,6 +53,6 @@ export function absoluteUrl(url, config) { if (typeof url !== "string") return ""; if (ABSOLUTE_URL.test(url)) return url; const rel = relativeUrl(url.startsWith("/") ? url : `/${url}`, normalizeBaseurl(config.baseurl)); - const siteUrl = String(config.url ?? ""); + const siteUrl = String(config.url ?? "").replace(/\/+$/, ""); return siteUrl === "" ? rel : new URL(siteUrl + rel).href; } From 7b2af61874459b993847285ada566e4e129a5600 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:47:35 +0200 Subject: [PATCH 06/33] a11y: STATE_AUDITS checked in buildMatrix and pick --check, not at load; --pages checked --- scripts/check_a11y_fingerprint.mjs | 46 ++++++++++++++++++++++++++---- scripts/check_axe_patch_equiv.mjs | 5 +++- scripts/lib/axe-scan.mjs | 20 +++++++++---- scripts/lib/cli-cases.mjs | 15 ++++++++++ scripts/pick_a11y_sample.mjs | 14 ++++++++- 5 files changed, 86 insertions(+), 14 deletions(-) diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index e71f7dd39..d28d6c8c3 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -55,8 +55,8 @@ // Requires `build.bat` to have produced an up-to-date _site-offline/. // Exit codes: 0 identical, 1 fingerprints differ, 2 a refused command line or a crash. -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; import { DEFAULT_ROOT_DIR, SAMPLE_PAGES, @@ -74,7 +74,10 @@ import { SOURCE_PATCHES, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { choiceOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, choiceOption, exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; + +// A crash exits 2, where 1 is a fingerprint that differs. +exitOnCrash(); // ---- CLI ------------------------------------------------------------------ const cli = withUsageError(() => @@ -130,14 +133,45 @@ let candidateLabel = cli.values.candidate; let rootDir = cli.values.rootDir; let themeArg = cli.values.theme; let viewportArg = cli.values.viewport; -let pagesArg = cli.values.pages !== undefined ? cli.values.pages.split(",") : null; let outFile = cli.values.out ?? null; let unminified = cli.values.unminified; let patchesArg = cli.values.patches; +rootDir = resolve(rootDir); + +// A --pages value: a comma-separated list of root-relative page paths, each a +// file in the tree being scanned. A name the tree lacks would otherwise reach +// the browser as a failed navigation, or be dropped from the matrix unseen. +function pagesOption(value) { + const pages = value + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (pages.length === 0) { + throw new CliError("bad-pages", `--pages expects a comma-separated list of pages, got: ${value}`, { + option: "--pages", + value, + }); + } + for (const page of pages) { + if (!page.startsWith("/")) { + throw new CliError("bad-pages", `--pages expects each page to start with "/", got: ${page}`, { + option: "--pages", + value: page, + }); + } + if (!statSync(join(rootDir, page), { throwIfNoEntry: false })?.isFile()) { + throw new CliError("bad-pages", `--pages expects pages that exist under ${rootDir}, got: ${page}`, { + option: "--pages", + value: page, + }); + } + } + return pages; +} const schemeNames = Object.keys(SCHEMES); const patchNames = Object.keys(SOURCE_PATCHES); -const { baseline, candidate } = withUsageError(() => { +const { baseline, candidate, pagesArg } = withUsageError(() => { for (const name of patchesArg .split(",") .map((x) => x.trim()) @@ -147,9 +181,9 @@ const { baseline, candidate } = withUsageError(() => { return { baseline: getScheme(choiceOption(baselineLabel, { option: "--baseline", choices: schemeNames })), candidate: getScheme(choiceOption(candidateLabel, { option: "--candidate", choices: schemeNames })), + pagesArg: cli.values.pages !== undefined ? pagesOption(cli.values.pages) : null, }; }); -rootDir = resolve(rootDir); const matrix = buildMatrix({ pages: pagesArg ?? SAMPLE_PAGES, diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs index 91653f51e..44be765be 100644 --- a/scripts/check_axe_patch_equiv.mjs +++ b/scripts/check_axe_patch_equiv.mjs @@ -29,7 +29,10 @@ import { DEFAULT_ROOT_DIR, VIEWPORTS, gotoPage, newAuditPage, SOURCE_PATCHES, readAxeSource } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { choiceOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; + +// A crash exits 2, where 1 is a value that differs. +exitOnCrash(); const cli = withUsageError(() => parseCli(process.argv.slice(2), { diff --git a/scripts/lib/axe-scan.mjs b/scripts/lib/axe-scan.mjs index 15b77a2ee..1be171014 100644 --- a/scripts/lib/axe-scan.mjs +++ b/scripts/lib/axe-scan.mjs @@ -211,12 +211,19 @@ export const STATE_AUDITS = [ }, ]; -for (const { filePath, state } of STATE_AUDITS) { - if (!SAMPLE_PAGES.includes(filePath)) { - throw new Error(`STATE_AUDITS: ${filePath} is not in SAMPLE_PAGES, so --pages narrowing would drop it silently`); - } - if (!Object.hasOwn(PAGE_STATES, state)) { - throw new Error(`STATE_AUDITS: unknown state "${state}"`); +/** + * Throws when a STATE_AUDITS entry names a page outside SAMPLE_PAGES or a + * state PAGE_STATES does not have. A function rather than a loop at module + * level, so that the tool importing this module decides where the throw ends. + */ +export function assertStateAudits() { + for (const { filePath, state } of STATE_AUDITS) { + if (!SAMPLE_PAGES.includes(filePath)) { + throw new Error(`STATE_AUDITS: ${filePath} is not in SAMPLE_PAGES, so --pages narrowing would drop it silently`); + } + if (!Object.hasOwn(PAGE_STATES, state)) { + throw new Error(`STATE_AUDITS: unknown state "${state}"`); + } } } @@ -686,6 +693,7 @@ export function buildMatrix({ viewports = Object.keys(VIEWPORTS), stateAudits = STATE_AUDITS, } = {}) { + assertStateAudits(); // The backstop behind pick(), for a caller that builds a matrix without it. for (const theme of themes) { if (!THEMES.includes(theme)) { diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs index 0d0c93775..c70eba6bc 100644 --- a/scripts/lib/cli-cases.mjs +++ b/scripts/lib/cli-cases.mjs @@ -812,6 +812,21 @@ bad("scripts/check_a11y_fingerprint.mjs", ["--baseline", "nope"], /^--baseline e bad("scripts/check_a11y_fingerprint.mjs", ["--candidate", "nope"], /^--candidate expects production, .+, got: nope\n$/); bad("scripts/check_a11y_fingerprint.mjs", ["--patches", "nope"], /^--patches expects .+, got: nope\n$/); bad("scripts/check_a11y_fingerprint.mjs", ["--patches=plain-color-fields,nope"], /^--patches expects .+, got: nope\n$/); +bad( + "scripts/check_a11y_fingerprint.mjs", + ["--root-dir", ".", "--pages", "404.html"], + '--pages expects each page to start with "/", got: 404.html\n', +); +bad( + "scripts/check_a11y_fingerprint.mjs", + ["--root-dir", ".", "--pages", " , "], + "--pages expects a comma-separated list of pages, got: , \n", +); +bad( + "scripts/check_a11y_fingerprint.mjs", + ["--root-dir", ".", "--pages", "/nope.html"], + /^--pages expects pages that exist under .+, got: \/nope\.html\n$/, +); bad("scripts/check_axe_patch_equiv.mjs", ["--patch", "nope"], /^--patch expects .+, got: nope\n$/); bad("scripts/check_links_diff.mjs", ["--max-lines", "abc"], NOT_WHOLE("--max-lines", "abc") + "\n"); bad("scripts/check_links_diff.mjs", ["--max-lines=-1"], NOT_WHOLE("--max-lines", -1) + "\n"); diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index c48677737..f7956bc5b 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -44,7 +44,16 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve, join, relative, sep } from "node:path"; -import { DEFAULT_ROOT_DIR, REPO_ROOT, SAMPLE_PAGES, discoverPages, median, pad, splitStubs } from "./lib/axe-scan.mjs"; +import { + DEFAULT_ROOT_DIR, + REPO_ROOT, + SAMPLE_PAGES, + assertStateAudits, + discoverPages, + median, + pad, + splitStubs, +} from "./lib/axe-scan.mjs"; import { exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; // A crash exits 2, where 1 is a coverage gap. @@ -155,6 +164,9 @@ const budget = withUsageError(() => { return cli.values.budget !== undefined ? numberOption(cli.values.budget, { option: "--budget", above: 0 }) : Infinity; }); const mode = ["check", "propose", "census"].find((m) => cli.values[m]) ?? "check"; +// The check is what check.bat runs to guard the sample, and STATE_AUDITS is +// part of it: a state audit on a page outside SAMPLE_PAGES is a coverage fault. +if (mode === "check") assertStateAudits(); let rootDir = cli.values.rootDir; let sweepPath = cli.values.sweep; let fresh = cli.values.fresh; From 944c970221d6667f5aadbe900731c143cb943b9d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:47:36 +0200 Subject: [PATCH 07/33] A failed probe exits 1 in check_ci_workflows and check_regex_safety; a shard crash exits 2 --- docs/Documentation/Tools.md | 6 +++--- scripts/check_ci_workflows.mjs | 9 +++++---- scripts/check_regex_safety.mjs | 33 +++++++++++++++++++-------------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 93270aaa8..e45b910b3 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -488,9 +488,9 @@ It gates on **exponential only**. recheck also reports polynomial blowup, and ab Two sets of probes run inside the normal pass rather than behind `--self-test`, because a green line saying *no exponential regex* is otherwise indistinguishable from a gate that has stopped detecting them. Eight are regexes with known answers in both directions, including the three this repository actually shipped. Fourteen more cover the folding: eight constructions that must resolve to an exact pattern, and six that must be refused with a reason --- a folder that quietly resolves nothing moves every construction into the unresolved list and the run still passes. -A failure of the gate itself is a 2 rather than a 1, because each of those leaves something unchecked; a 2 wins over a 1 when both happen in one run. That is the [convention for a gate's exit codes](Extending#conventions). +A probe that comes back wrong is a 1, like a finding: the gate ran and its verdict cannot be trusted. A failure to do the job at all is a 2 rather than a 1, because each of those leaves something unchecked --- a file that would not parse, a regex that could not be analysed, a crash --- and a 2 wins over a 1 when both happen in one run. That is the [convention for a gate's exit codes](Extending#conventions). -Exit codes: **0** no regex can backtrack exponentially (with `--self-test`, every probe was classified correctly); **1** a regex can backtrack exponentially; **2** the gate could not run, and 2 wins over 1: a refused command line, a file it could not parse, a regex it could not analyse, a probe that came back wrong (also `--self-test`), or a crash. +Exit codes: **0** no regex can backtrack exponentially (with `--self-test`, every probe was classified correctly); **1** a regex can backtrack exponentially, or a probe came back wrong (also `--self-test`); **2** the gate could not run, and 2 wins over 1: a refused command line, a file it could not parse, a regex it could not analyse, or a crash. ### check_code_regions.mjs {: #check-code-regions } @@ -549,7 +549,7 @@ The differences that are meant are listed in the script, each with where it is r Its probes ride along in every run: each plants one defect in a small synthetic set of wrappers, workflows and actions --- a missing gate, a step no wrapper runs, two gates swapped, changed arguments, a build flag lost or added, a gate missing from the shared action, a workflow that stops calling it --- and requires exactly the findings it should produce. Pure text: no browser, no built tree. -Exit codes: **0** both workflows run every gate the wrappers run, and its own probes pass; **1** a workflow differs from the wrappers (a finding is listed); **2** the gate could not run: a refused command line, a failed probe, or a crash. +Exit codes: **0** both workflows run every gate the wrappers run, and its own probes pass; **1** a workflow differs from the wrappers (a finding is listed), or one of its probes failed; **2** the gate could not run: a refused command line, or a crash. ### check_lint.mjs {: #check-lint } diff --git a/scripts/check_ci_workflows.mjs b/scripts/check_ci_workflows.mjs index 747c3da86..eb3124714 100644 --- a/scripts/check_ci_workflows.mjs +++ b/scripts/check_ci_workflows.mjs @@ -24,7 +24,7 @@ // // node scripts/check_ci_workflows.mjs // -// Exit codes: 0 clean, 1 a finding, 2 a refused command line, a failed probe or a crash. +// Exit codes: 0 clean, 1 a finding or a failed probe, 2 a refused command line or a crash. import { readFileSync } from "node:fs"; import path from "node:path"; @@ -44,8 +44,9 @@ arguments and in the same order. Exit codes: 0 both workflows run every gate the wrappers run, and its own probes pass - 1 a workflow differs from the wrappers: a finding is listed - 2 the gate could not run: a refused command line, a failed probe, or a crash`; + 1 a workflow differs from the wrappers: a finding is listed, or one of the + gate's own probes failed + 2 the gate could not run: a refused command line, or a crash`; if ( withUsageError(() => @@ -346,7 +347,7 @@ const probeFailures = runProbes(); if (probeFailures.length) { console.error(`check_ci_workflows: ${probeFailures.length} of ${PROBES.length} probes failed:`); for (const f of probeFailures) console.error(` ${f}`); - process.exit(2); + process.exit(1); } console.log(`check_ci_workflows: ${PROBES.length} probes, all pass`); diff --git a/scripts/check_regex_safety.mjs b/scripts/check_regex_safety.mjs index 838093cfe..8c0557c07 100644 --- a/scripts/check_regex_safety.mjs +++ b/scripts/check_regex_safety.mjs @@ -59,10 +59,11 @@ // node scripts/check_regex_safety.mjs --census # full classification // node scripts/check_regex_safety.mjs --self-test # prove it still detects // -// Exits 0 clean, 1 on an exponential regex, 2 when the gate itself failed -// -- a refused command line, a file it could not parse, a regex recheck could -// not analyse, a probe that came back wrong, or a throw. Each of those leaves -// something unchecked, so it must not read as either a clean tree or a finding. +// Exits 0 clean, 1 on an exponential regex or a self-test probe that came back +// wrong, 2 when the gate could not do its job -- a refused command line, a file +// it could not parse, a regex recheck could not analyse, or a throw. Each of +// those leaves something unchecked, so it must not read as either a clean tree +// or a finding, and 2 wins over 1. import { spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -77,7 +78,7 @@ import * as walk from "acorn-walk"; import fg from "fast-glob"; import { foldConstructedRegexes, moduleExports } from "./lib/regex-fold.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // ── Backend selection ──────────────────────────────────────────────────────── @@ -526,23 +527,24 @@ async function gate({ census }) { for (const u of unresolved) console.log(` ${u.file}:${u.line} ${u.reason}`); } - // `failed` is a finding in the tree (exit 1); `broken` is the gate - // itself failing (exit 2), and wins, because a verdict from a gate that - // did not check everything is not a verdict. + // `failed` is a finding (exit 1): an exponential regex in the tree, or a + // self-test probe that came back wrong. `broken` is the gate unable to + // read or analyse the tree (exit 2), and wins, because a verdict from a + // gate that did not check everything is not a verdict. let failed = false; let broken = false; const misclassified = probeResults.filter( (r) => r.verdict === "error" || (r.verdict === "exponential") !== (r.probe === "exponential"), ); if (misclassified.length) { - broken = true; + failed = true; console.error(`\nFAIL: ${misclassified.length} of ${probeResults.length} self-test probes misclassified.`); for (const r of misclassified) console.error(` ${r.name}: expected ${r.probe}, got ${r.verdict}`); console.error(" The gate is not measuring what it claims; its verdict above means nothing."); } const badFolds = foldProbes.filter(([ok]) => !ok); if (badFolds.length) { - broken = true; + failed = true; console.error(`\nFAIL: ${badFolds.length} of ${foldProbes.length} fold probes failed.`); for (const [, name, detail] of badFolds) console.error(` ${name}${detail ? `: ${detail}` : ""}`); console.error( @@ -606,7 +608,7 @@ async function selfTest() { } if (bad) { console.error(`\nFAIL: ${bad} probe(s) wrong -- the gate is not measuring what it claims.`); - return 2; + return 1; } console.log( `ok ${results.length} classification + ${FOLD_PROBES.length + FOLD_NEGATIVES.length} ` + @@ -626,10 +628,10 @@ Refuses a regex literal in the tree that can backtrack exponentially. Exit codes: 0 no regex can backtrack exponentially (--self-test: every probe was classified correctly) - 1 a regex can backtrack exponentially + 1 a regex can backtrack exponentially, or a self-test probe came back wrong + (also --self-test) 2 the gate could not run, and 2 wins over 1: a refused command line, a file it - could not parse, a regex it could not analyse, a probe that came back wrong - (also --self-test), or a crash`; + could not parse, a regex it could not analyse, or a crash`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { @@ -643,6 +645,9 @@ const { values } = withUsageError(() => }), ); if (values.help) printHelpAndExit(USAGE); +// The shard branch has no try/catch of its own, and Node's exit code for an +// unhandled throw is 1. +exitOnCrash(); if (values.shard) { // Worker half of checkAll(): a slice in on stdin, its verdicts out on // stdout. Not meant to be run by hand. From 485fa24159d749648251ff5cfdadd2ce7f89373f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:58:45 +0200 Subject: [PATCH 08/33] build_fonts.py parses its command line: --help prints usage and builds nothing --- docs/Documentation/Tools.md | 4 +++- scripts/build_fonts.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index e45b910b3..9472b574a 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -778,7 +778,9 @@ Exit codes: **0** no accessibility violation was found, **1** the sweep found at python -m pip install "fonttools[woff]" python scripts/build_fonts.py -Regenerates the subset webfonts under `docs/assets/fonts/` from pinned upstream releases (SHA-256 verified), pinning the optical-size axis and keeping `wght` variable. Development tooling only: the `.woff2` files are committed like the generated DOT SVGs, and `build.bat` needs neither Python nor a network connection --- though the PDF pass aborts if one of the faces it needs is missing from the source tree, naming this script. **Regenerating Inter means regenerating the diagram metrics too** --- see below. Changing a face rather than refreshing one reaches well beyond this script; [Changing a typeface](Builder#changing-a-typeface) lists every place the build names one. +Regenerates the subset webfonts under `docs/assets/fonts/` from pinned upstream releases (SHA-256 verified), pinning the optical-size axis and keeping `wght` variable. Development tooling only: the `.woff2` files are committed like the generated DOT SVGs, and `build.bat` needs neither Python nor a network connection --- though the PDF pass aborts if one of the faces it needs is missing from the source tree, naming this script. **Regenerating Inter means regenerating the diagram metrics too** --- see below. Changing a face rather than refreshing one reaches well beyond this script; [Changing a typeface](Builder#changing-a-typeface) lists every place the build names one. The script takes no options except `-h` and `--help`, which print the usage and start nothing. + +Exit codes: **0** the faces were written; **1** a dependency is missing, an archive's SHA-256 differs from the pinned one, or a build step failed; **2** a refused command line. ### build_dot_metrics.mjs {: #build-dot-metrics } diff --git a/scripts/build_fonts.py b/scripts/build_fonts.py index 4c3554bc6..8941dee17 100644 --- a/scripts/build_fonts.py +++ b/scripts/build_fonts.py @@ -36,6 +36,7 @@ platform already ships one. """ +import argparse import hashlib import io import shutil @@ -185,6 +186,12 @@ def build_face(archive, member, axes, unicodes, out_name): def main(): + argparse.ArgumentParser( + description=__doc__.split("\n\n", 1)[0], + epilog="Exit codes: 0 the faces were written; 1 a missing dependency," + " a SHA-256 mismatch or a failed build; 2 a refused command line.", + ).parse_args() + try: import fontTools # noqa: F401 import brotli # noqa: F401 From d7f1fa39040696af68061e12ccec3376da0bb07a Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:58:45 +0200 Subject: [PATCH 09/33] wisdom extract names the manifest it wrote, not the default path --- wisdom/extract/prep.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wisdom/extract/prep.mjs b/wisdom/extract/prep.mjs index 214a76501..2ee1db0e0 100644 --- a/wisdom/extract/prep.mjs +++ b/wisdom/extract/prep.mjs @@ -235,11 +235,16 @@ export async function runExtract(flags) { config, batches, }; - writeFileSync(join(outDir, "extract-manifest.json"), JSON.stringify(manifest, null, 2)); + const manifestPath = join(outDir, "extract-manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const relManifest = manifestPath + .split(sep) + .join("/") + .replace(/^.*?wisdom\//, "wisdom/"); process.stderr.write( `[wisdom] ${batches.length} batches written (${BATCH_SIZE} threads each, ${allThreads.length} total)\n` + - `[wisdom] Manifest: wisdom/data/findings/extract-manifest.json\n` + + `[wisdom] Manifest: ${relManifest}\n` + `[wisdom] Invoke the extract workflow for each batch from Claude Code, then run ` + `'node wisdom/wisdom.mjs extract --merge' to graft the results into staging.md.\n`, ); From e6e24f5d83d48b268a554643d039f8851a31d18d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:58:45 +0200 Subject: [PATCH 10/33] Look for the browser before puppeteer.launch, which leaves a profile folder when it is missing --- book/render-book.mjs | 8 ++++++++ scripts/lib/browser.mjs | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/book/render-book.mjs b/book/render-book.mjs index 377cf70dd..19750929c 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -274,6 +274,14 @@ const fmtMs = (ms) => (ms / 1000).toFixed(1) + "s"; // --- launch + render --------------------------------------------------- +// puppeteer.launch creates its temporary profile folder before it looks for +// the browser, so a launch refused for a missing browser would leave the folder +// behind. scripts/lib/browser.mjs's requireBrowser makes the same check. +const browserPath = await puppeteer.executablePath(); +if (!existsSync(browserPath)) { + throw new Error(`Tried to find the browser at the configured path (${browserPath}), but no executable was found.`); +} + const browser = await puppeteer.launch({ headless: true, // --allow-file-access-from-files is critical: without it paged.js's diff --git a/scripts/lib/browser.mjs b/scripts/lib/browser.mjs index f4fd558e3..1781d1c32 100644 --- a/scripts/lib/browser.mjs +++ b/scripts/lib/browser.mjs @@ -10,6 +10,7 @@ // finally, so the folder is deleted on every exit path, including an // assertion that throws on purpose. +import { existsSync } from "node:fs"; import puppeteer from "puppeteer"; // --no-sandbox: GitHub's ubuntu-24.04 runners carry the AppArmor restriction @@ -20,7 +21,18 @@ import puppeteer from "puppeteer"; // passes the same pair for the same reason. const LAUNCH_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]; -export function launchBrowser(opts = {}) { +// puppeteer.launch creates the temporary profile folder before it looks for the +// browser, so a launch refused for a missing browser leaves the folder behind. +// The browser is looked for first, with puppeteer's own message. +async function requireBrowser({ executablePath, channel }) { + if (executablePath || channel) return; + const found = await puppeteer.executablePath(); + if (!existsSync(found)) + throw new Error(`Tried to find the browser at the configured path (${found}), but no executable was found.`); +} + +export async function launchBrowser(opts = {}) { + await requireBrowser(opts); return puppeteer.launch({ headless: true, args: LAUNCH_ARGS, ...opts }); } From c8aa8aaa7b60ccf22fa29c9c35727f675c938544 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 15:58:45 +0200 Subject: [PATCH 11/33] declarationKind: in a Type or Union, a field named for a keyword is a field --- scripts/check_twin_parsers.mjs | 9 +++++++++ scripts/lib/twin-declarations.mjs | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/check_twin_parsers.mjs b/scripts/check_twin_parsers.mjs index bbd89388a..887efd664 100644 --- a/scripts/check_twin_parsers.mjs +++ b/scripts/check_twin_parsers.mjs @@ -98,6 +98,15 @@ for (const [decl, container, want] of [ ["Red = 1", "Enum", "EnumMember"], ["Width As Long", "Type", "TypeMember"], ["Width As Long", "Union", "TypeMember"], + // A field named for a keyword is a field, not a nested declaration: the + // keyword rule would file `Type As Long` as a Type. + ["Type As Long", "Type", "TypeMember"], + ["Type(3) As Byte", "Type", "TypeMember"], + ["Union As Long", "Type", "TypeMember"], + ["Enum As Long", "Union", "TypeMember"], + ["Private Type Inner", "Type", "Type"], + ["End Type", "Type", null], + ["Type Foo", "Module", "Type"], // A Const is not a variable: Attributes.md states the two as different // targets, and [DllExport] is documented on one and refused on the other. ["Private Const Answer As Long = 42", "Module", "Const"], diff --git a/scripts/lib/twin-declarations.mjs b/scripts/lib/twin-declarations.mjs index 4a45ac415..abff82e61 100644 --- a/scripts/lib/twin-declarations.mjs +++ b/scripts/lib/twin-declarations.mjs @@ -34,6 +34,7 @@ const DECL_RE = new RegExp( `Function|Property|Event|DeclareWide|Declare|Implements)\\b`, "i", ); +const TYPE_FIELD_RE = /^\s*\w+(?:\s*\([^)]*\))?\s+As\s+/i; const VAR_RE = new RegExp(`^\\s*(?:${MODIFIERS}|Const|Dim)\\s+[\\w\\[]`, "i"); /** @@ -48,6 +49,9 @@ const VAR_RE = new RegExp(`^\\s*(?:${MODIFIERS}|Const|Dim)\\s+[\\w\\[]`, "i"); */ export function declarationKind(decl, container) { const d = decomment(decl); + // A field may be named for a keyword: `Type As Long`, `Union(3) As Byte`. In a + // Type or Union block the field rule is tried before the keywords. + if ((container === "Type" || container === "Union") && TYPE_FIELD_RE.test(d)) return "TypeMember"; const m = DECL_RE.exec(d); if (m) { const k = m[1]; @@ -55,9 +59,6 @@ export function declarationKind(decl, container) { } if (/^\s*End\s+\w/i.test(d)) return null; // unresolved if (container === "Enum" && /^\s*\[?\w/.test(d)) return "EnumMember"; - if (container === "Type" || container === "Union") { - if (/^\s*\w+\s+As\s+/i.test(d)) return "TypeMember"; - } // The distinction matters: `Attributes.md` states "constants in a // module" and "variables in a Class" as different targets, and [DllExport] // is documented on a Const and refused on a variable. From 6f4d130b5bb6c3ba902f1d3ddc7fff1a2b816069 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 16:02:59 +0200 Subject: [PATCH 12/33] WIP files: history dropped or restated as present-tense rules --- WIP.ExamplesBuild.md | 95 ++++++++++++++++++++-------------------- WIP.Harness.md | 75 ++++++++++++++++---------------- WIP.HelpAddin.md | 18 ++++---- WIP.Search.md | 100 +++++++++++++++++++------------------------ WIP.Typography.md | 11 ++--- WIP.md | 6 +-- 6 files changed, 144 insertions(+), 161 deletions(-) diff --git a/WIP.ExamplesBuild.md b/WIP.ExamplesBuild.md index 7cef1ff1d..fec20fa10 100644 --- a/WIP.ExamplesBuild.md +++ b/WIP.ExamplesBuild.md @@ -23,9 +23,9 @@ that are left are not programs (see [What cannot be marked](#what-cannot-be-mark ## The problem A `tb` fence is something `check_code_regions.mjs` protects the *contents* of and never -evaluates, so every gate was green over samples that do not compile: an icon key in a slot -the same package's prose says is validated, a `Sub` with no name, and the five -`Dim X As New Worksheet` samples in `Core/` (`Worksheet` is an Excel type, and in a bare +evaluates, so every gate can be green over samples that do not compile: an icon key in a slot +the same package's prose says is validated, a `Sub` with no name, and +`Dim X As New Worksheet` (`Worksheet` is an Excel type, and in a bare twinBASIC project that line does not compile at all). ## Why this is not part of a build @@ -63,8 +63,8 @@ marking work is planned from, and it needs no compiler. It does not claim any of compile; only `--propose` knows that. A fence that reads as unbalanced is usually a classifier that does not know the language, -not a sample that is incomplete. Three classifier gaps once produced most of the fragments. -Each is a probe in `scripts/lib/example-batches.mjs`, which `check_examples.mjs` and +not a sample that is incomplete. Three things a classifier must know account for most +spurious fragments. Each is handled, with a probe in `scripts/lib/example-batches.mjs`, which `check_examples.mjs` and `test.bat` both run: - **an `Interface` body holds prototypes.** `Sub Bar()` inside one has no `End Sub`, so @@ -74,7 +74,7 @@ Each is a probe in `scripts/lib/example-batches.mjs`, which `check_examples.mjs` (`Features/Language/UDTs.md`). A UDT *field* may also be called `Type As Long`, which reads as an opener that never closes. So every opener demands a name after the keyword (`scripts/census_attributes.mjs` records the same trap). -- **`Overridable` is a modifier.** Without it in the list, a sample came back as *"End +- **`Overridable` is a modifier.** Without it in the list, a sample comes back as *"End Function closing Class"*. A missed opener always surfaces as a mismatch somewhere later, never where it happened. @@ -83,9 +83,8 @@ without a body, a syntax skeleton with ``. **Opt-in is right because many samples are correct as documentation and incomplete as programs**: a `With MyLabel` block with no `MyLabel`, a handler for a class the page does -not define. When opt-in was decided, 54% of the classifiable fences compiled and 46% did not. -Marking the 46% would be wrong, and opting them out one by one would be a list of five -hundred exceptions. Page edits and harness work have since brought nearly all of them in. +not define. Marking every fence would be wrong, and opting the incomplete ones out one by +one would be a list of hundreds of exceptions. ## The markup @@ -151,8 +150,8 @@ including `--census`. ### `inert` is a decision, not a suppression -An unmarked fence used to mean two things --- nobody has looked, and somebody looked and -concluded there is nothing to compile --- and the census could not tell them apart. +Without `inert`, an unmarked fence means two things --- nobody has looked, and somebody looked and +concluded there is nothing to compile --- and the census cannot tell them apart. `inert=` records the second. It **takes a reason rather than being a bare flag**: the reason makes the count readable and lets a later reader disagree with a specific judgement rather than with a silence. An unknown reason is refused the way a bad `slot=` is, @@ -166,9 +165,9 @@ today**. It marks correct code that a *product* defect stops compiling. It exist the alternative, rewriting the sample until the compiler accepts it, is not taken by accident. -Do not reach for it too early. Six enum members a default project cannot name (each enum -sits inside a `Private Module` or a private base class) were once marked `blocked`, until the -question *what would a reader actually do?* found the answer: the asterisk. `*WinNativeCommonCtls` +Do not reach for it too early. An enum member a default project cannot name (the enum +sits inside a `Private Module` or a private base class) is not `blocked`: the question +*what would a reader actually do?* has an answer, the asterisk. `*WinNativeCommonCtls` exposes the package's private half, and `WinNativeCommonCtls.ImlDrawTransparent` compiles. The templates `wnc-private` and `cef-private` join `vb-private` and `cc-private` for this, and each such page carries an IMPORTANT saying which library symbol to set and why. @@ -270,10 +269,9 @@ Rules: - **A page cannot hide what it also shows --- so rename the placeholder.** Hidden context joins every project holding a sample from its page, so a *visible* sample on that page declaring the same name meets it (`TB5137 'IFoo' is ambiguous`). - `Reference/Core/CoClass.md` is the case: its first example named `IFoo` and `IBar` without - declaring them, and its next example declares both in full. `IFoo` and `IBar` are arbitrary - placeholders, so the first example is now a `CoClass Shape` over `IShape` and `IDrawable`, - with those two interfaces in a hidden fence. The general rule: **when hidden context + `Reference/Core/CoClass.md` is the case: a later example declares `IFoo` and `IBar` in + full, and they are arbitrary placeholders, so the first example is a `CoClass Shape` over + `IShape` and `IDrawable`, with those two interfaces in a hidden fence. The general rule: **when hidden context collides with a visible sample, check whether the name is load-bearing before working around it.** - **Write hidden declarations at the top of their fence**, not inside a `Module` wrapper, @@ -433,7 +431,7 @@ names. **The residual is stated rather than closed:** two *ungrouped* samples still share a project and can still see each other. Nothing in twinBASIC hides a module's public members from the rest of a project, so the only complete fix is a project per sample, at 6--8 s -each. A real dependency is now written down. +each. A real dependency is written down with `projname=`. **The gate is the detector.** A `--propose` survey compiles every classifiable fence together and the gate compiles only marked ones, so they disagree by construction. @@ -478,9 +476,9 @@ are in the set for that reason. **`COLLIDES` reads a fence's *outermost container* names, so it is still too narrow for nested enums.** `Module MESSAGETABLE` is what it records, not the `Enum EVENTS` inside it nor that enum's members. twinBASIC puts enum members in project scope whatever container -declares them, so two pages whose hidden context each declared an `EVENTS` enum landed in +declares them, so two pages whose hidden context each declare an `EVENTS` enum land in one batch: `TB5000 duplicate definition [EVENTS]` and `TB5073 'status_changed' is -ambiguous`. The fix taken is `projname=` on each page, which buys an isolated project. +ambiguous`. The fix is `projname=` on each page, which gives it an isolated project. Tracking nested enum names in `COLLIDES` would be the general version, at the cost of splitting batches for names that usually do not collide. @@ -523,7 +521,7 @@ two as a module's is (checked against a real error in a class, a module and the The dispatcher opens the output file before each sample and closes it after, so `Class_Terminate` of an object released when the sample's `Sub` ends prints into it. Most twinBASIC syntax is not VB6, and a compile error there is the state `not VB6`, which is -information; a `differs` is what the tool is for. What building in VB6 taught +information; a `differs` is what the tool is for. The facts about building in VB6 (`scripts/lib/vb6.mjs`, which has the list): - `Debug.Print` writes nothing in a compiled exe, so each one is rewritten to `Print #511,`, @@ -570,9 +568,9 @@ the Form page's `True`). shows the compile has ended and the window agrees with it, or five one-second samples match. It waits for no build. An IDE under load can sit OPERATIONAL with an empty panel before it has published anything, and every sample of that batch then reads as compiling, - with nothing to tell it from a batch that has no errors. (`sweep_attributes` met it - first: one build in 136 of a full run drew `ACCEPT` for all three of its canaries, - including the invented name that can only draw an error. The cause is not established.) + with nothing to tell it from a batch that has no errors. (One build in 136 of a full + `sweep_attributes` run drew `ACCEPT` for all three of its canaries, including the invented + name that can only draw an error. The cause is not established.) Each batch carries `tbxCanary`, a module with `[EnforceWarnings(TB0005)]` and a `#Warning` directive, which must draw the warning TB0005. Its rows are taken out, at any severity, before anything else reads them. @@ -667,10 +665,10 @@ Three parts to the handling, each forced: Cost on this corpus: one blame in 1,103 samples, three extra builds. -**`--only` cuts a `projname` group.** Narrowing to `WinServicesLib/ServiceCreator` left the +**`--only` cuts a `projname` group.** Narrowing to `WinServicesLib/ServiceCreator` leaves the page that declares `MyService` out of the run while keeping three pages that instantiate -`ServiceCreator(Of MyService)`, so the isolation blamed a sample the full gate passes. -`checkGroups` now says so as an advisory finding. **A narrowed run's results are not a full +`ServiceCreator(Of MyService)`, so the isolation blames a sample the full gate passes. +`checkGroups` says so as an advisory finding. **A narrowed run's results are not a full run's, and the tool has to be the thing that says which.** ### `--build` and `--llvm`: what a compile does not ask @@ -741,20 +739,20 @@ that fails only the second is one LLVM cannot generate code for. ## Traps -- **`tbbuild` once reported a clean build on a project that crashed the compiler**, and - every number in this file depends on it not doing so. A 275-fence project reported `0 - errors, 0 warnings` and exit 0 while its quarters reported 129, 0, 294 and 448 errors. The +- **A project that crashes the compiler can look like a clean build**, and every number in + this file depends on `tbbuild` catching it. A 275-fence project shows `0 + errors, 0 warnings` and exit 0 while its quarters report 129, 0, 294 and 448 errors. The quarter that reported zero held three `NATIVE EXCEPTION: ACCESS_VIOLATION` lines and three `restarting from MEMORY`. A crash-restart cycle takes about 1.3 s, shorter than a 1 Hz status sample can see; after the third restart the IDE gives up and leaves the status at - OPERATIONAL with the counters at zero, byte-identical to a clean build. `tbbuild` now + OPERATIONAL with the counters at zero, byte-identical to a clean build. `tbbuild` reads the DEBUG CONSOLE, which nothing removes an entry from, and exits 4 naming the file the compiler died parsing. **Treat any tooling that infers "finished" from "stopped changing" as suspect on this compiler.** - **The classifier refuses a `` as a declaration name**, so a syntax skeleton such as `Interface Extends ` / `End Interface` (in `Reference/Attributes.md`) takes an explicit `slot=` to reach the compiler. Through BETA - 995 that skeleton crashed the compiler (twinbasic/twinbasic#2453, fixed in 997). + 995 that skeleton crashes the compiler (twinbasic/twinbasic#2453); BETA 997 does not. - **`project.buildPath` must be an explicit file.** The default `${SourcePath}\Build\...` template opens a native Save dialog, which on the private desktop is invisible and unreachable, so the build silently never happens --- and the WebView2 renderer stays @@ -789,20 +787,19 @@ that fails only the second is one LLVM cannot generate code for. was still running 563 s later. A job object is no answer either --- the server is not a descendant. - **`@($null).Count` is 1 in PowerShell.** A count of `Select-String … | Measure-Object` - results reported a hidden fence reaching all three built trees; it did not. **Count with - the tool whose empty result is empty** (`grep -c` said 0). + results reports a hidden fence reaching all three built trees when it reaches none. + **Count with the tool whose empty result is empty** (`grep -c` says 0). - **The shell eats backslashes** in a regex passed through `node -e` from bash, turning - `'...symbol .(.+?).'` into a pattern that matched one character and producing a + `'...symbol .(.+?).'` into a pattern that matches one character and produces a plausible-looking table of single-letter "undeclared symbols". This is the heredoc trap in WIP.md's Don'ts arriving through `-e`. Write the script to a file. ## Rules from the survey passes -The compiling share went from 54% to nearly all of the classifiable corpus through harness -work and page edits. What a maintainer still needs from that work follows. +What a maintainer needs from the survey work follows. **Count diagnostics, then try the fix.** A guess from counting diagnostics is not a result. -"`[Me]` wants a fourth slot" (27 samples, TB5025) was built and measured: a bare `Class` +"`[Me]` wants a fourth slot" (27 samples, TB5025): a bare `Class` wrapper fixes **5**, because `Me` becoming legal does not make `Me.Caption` resolve: the wrapper has no `Caption` (`TB5027 [tbx_…] does not contain 'Caption'`). The largest win was the stage set. @@ -881,8 +878,8 @@ Language rules this work found: section shows a class the reader defines, carries `[COMCreatable(False)]` (a class whose only constructor takes arguments cannot supply the parameterless one COM creation wants), and says in a NOTE that `As New` with arguments is a syntax error. -- **The elision the VBA-derived pages use is `. . .`, spaced.** The classifier knew only - `...`, and proposed three such pages as markable; they failed on *"Expected a symbol +- **The elision the VBA-derived pages use is `. . .`, spaced.** A classifier that knows only + `...` proposes such pages as markable, and they fail on *"Expected a symbol following the dot operator"*. - **A class cannot implement an interface member marked `[PreserveSig]`**, and the diagnostic "expects" the signature already written (BUGS-TO-REPORT.md has the @@ -903,8 +900,8 @@ Language rules this work found: | `Assert.IsTrue x > 0` | `TB5027 Unrecognized member 'IsTrue' on type 'Assert'` | | `IsTrue x > 0` | `TB5079 Unrecognized symbol 'IsTrue'` | - Five pages wrote the bare form at 66 sites, because one sentence on the package index - presented `Assert.` as disambiguation needed only on a clash. **When a sample is wrong + The bare form is what a reader writes when the package index presents `Assert.` as + disambiguation needed only on a clash. **When a sample is wrong across a whole package, check whether a sentence taught it**; prefixing the calls without rewriting that sentence leaves the prefixes reading as optional noise. All `Syntax:` lines carry the namespace too: a `Syntax:` line is the first thing a reader copies, and a @@ -913,7 +910,7 @@ Language rules this work found: ### What cannot be marked -The 52 `inert` fences are not programs and were never going to be: syntax skeletons with +The 52 `inert` fences are not programs: syntax skeletons with `` names (`Interfaces-CoClasses.md` writes `Inherits base_interface`), property-assignment lines shown outside their `With` (`WinServicesLib/ServiceManager.md` documents each property as `.Name = "..."`), continuation fences that deliberately reuse the @@ -922,9 +919,9 @@ content. Their reasons are the `inert=` values. `--propose --json` writes a surv `--report ` groups it by diagnostic, section and unresolved name. A genuine fragment (`TB5182`) is an elision, a signature with no body, or pseudo-code in a -`tb` fence. It needs the same judgement page by page; `VB/MDIForm/index.md`'s pseudo-code -menu table (`=>`) became four real handlers, which both compile and are what a reader -writes. +`tb` fence. It needs the same judgement page by page; `VB/MDIForm/index.md` shows its menu +table as four real handlers rather than pseudo-code (`=>`), which both compile and are what +a reader writes. ## Open questions @@ -936,8 +933,8 @@ writes. - **Should the remaining single-page stage entries move into `hidden` fences?** About a dozen control instances are used by exactly one page --- `picCanvas`, `hsbVolume`, `mfPanels`, `lblCoords`. They are one line each in a list whose stated purpose is - "the control instances the samples assume", so they stay. The invented *classes* moved, - because a class is not a control instance and a page is where it belongs. The line between + "the control instances the samples assume", so they stay. The invented *classes* go in + `hidden` fences, because a class is not a control instance and a page is where it belongs. The line between the two is a judgement, not a rule. - Which pages should carry `check_run` (33 samples do)? A sample states its output in its own text, as a trailing comment on a `Debug.Print` line or as the comment lines under diff --git a/WIP.Harness.md b/WIP.Harness.md index 95beb79f4..ff4f457f0 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -74,9 +74,9 @@ the only prose anywhere explaining `[WithDispatchForwarding]` is a comment in Sa > `(`. Argument text needs stripping too, or `[Description("Sets or returns, given …")]` > contributes an attribute named `given`. -**A worked instance: export settles what the pages cannot.** Two reference pages disagreed -with two others about whether a form's code uses bare event handlers. One `export` settles -it --- every form code-behind in the shipped samples is: +**A worked instance: export settles what the pages cannot.** Whether a form's code uses +bare event handlers is a question one `export` settles --- every form code-behind in the +shipped samples is: ``` [Description("")] @@ -88,8 +88,8 @@ Class ChildBlue End Class ``` -So a form's `.twin` file **is** a `Class` with designer attributes, the pages that wrapped -their samples were right, and the bare-handler pages show excerpts. `Form_Load` is what the +So a form's `.twin` file **is** a `Class` with designer attributes, a page that wraps +its samples in one is right, and a bare-handler page shows an excerpt. `Form_Load` is what the samples use; `UserForm_Initialize` appears in none of them. The pages are the thing in doubt, so only the export can decide. @@ -126,7 +126,7 @@ from the other tool. `gen_attribute_probes.mjs` records the converse trap under Property Get, a Function and a Sub", and the probe returned TB5155 because all 82 uses are inside an Interface. Group by enclosing construct. -**Seven ways a sweep of this corpus gets a wrong answer**, each measured, and each now a +**Seven ways a sweep of this corpus gets a wrong answer**, each measured, and each a comment in the file: - A **line matcher misses 292 of 7,604 attribute lines (3.8%)**, because @@ -165,8 +165,8 @@ corpus oddity. A census says where the packages *use* an attribute, and `gen_attribute_probes.mjs` probes only the targets `Attributes.md` already claims, so an entry that is too short stays too -short: `[ComExport]` was documented as "constants in a Module" because a Sub and a Const were -the two targets tried, and an API `Declare` never was. +short: an entry written from the two targets someone tried, a Sub and a Const, says +"constants in a Module" and never meets the API `Declare` the attribute also takes. [scripts/sweep_attributes.mjs](scripts/sweep_attributes.mjs) asks every question --- every name (the page's, the compiler's token table's, `--names`) at each of about 60 sites in [scripts/lib/attribute-sites.mjs](scripts/lib/attribute-sites.mjs), in each argument shape --- @@ -188,7 +188,7 @@ candidate. Swept, exactly one name the page does not document is accepted anywhe `[PropertyPage]`, at eighteen sites, all of them members of a Class or an Interface. The other 193 are refused at every site. -**Six things the sweep has to get right, each of which gave a wrong report first:** +**Six things the sweep has to get right, each of which gives a wrong report when missed:** - **`parseCli` camel-cases its keys.** `values["dry-run"]` is `undefined`, so `--dry-run` is ignored and a "dry run" becomes a full four-lane sweep. Read `values.dryRun`; run a new @@ -218,8 +218,8 @@ other 193 are refused at every site. **It finds compiler crashes the documentation never would:** a crash in a batch is isolated by halving down to one probe beside the canaries, which is how a bare -`[PopulateFrom]` on an Enum was found to kill the compiler (twinbasic/twinbasic#2450, fixed -in BETA 997). +`[PopulateFrom]` on an Enum is found to kill the compiler (twinbasic/twinbasic#2450; BETA 997 +does not crash). **Read the report's "accepted at most sites" section before believing an acceptance.** `[Description]` is taken at 53 of 61 sites and `[Hidden]` and `[Restricted]` at 42: either @@ -252,7 +252,7 @@ are 0 clean, 1 the project has errors, 2 the harness failed or the project file **A project file the IDE cannot read is refused before an IDE starts.** `tbbuild` reads the container with impexp's reader (`checkProject`, in [scripts/lib/tb-project.mjs](scripts/lib/tb-project.mjs)) and exits 2 within a moment, -naming the file and the fault. Without that, BETA 995 gave exit 0, a clean compile with no +naming the file and the fault. Without that, BETA 995 gives exit 0, a clean compile with no rows, after the whole `--timeout` (66 s at `--timeout 60`) for an empty file, a file cut off halfway and a file with its first byte changed, and exit 3 for a text file. The IDE answers such a file with a message box, which nobody can see or answer on the private desktop. The @@ -305,16 +305,16 @@ does not. **The mechanics are one library, [scripts/lib/tb-ide.mjs](scripts/lib/tb-ide.mjs)**: starting the IDE, attaching, waiting for the compile, reading the diagnostics and the DEBUG CONSOLE, clicking, building, and ending the process tree. `tbbuild` and `tbrun` are command lines -around it, and the add-in harness planned in [WIP.HelpAddin.md](WIP.HelpAddin.md) is built +around it, and the add-in test runner ([WIP.HelpAddin.md](WIP.HelpAddin.md)) is built on it. **A build is also a function, [scripts/lib/tb-build.mjs](scripts/lib/tb-build.mjs)'s `compileProject`**, which is `tbbuild` without its command line and returns `{code, message, rows, counts, dialogs, crashFiles, ...}`. `check_examples` and -`sweep_attributes` call it rather than starting `tbbuild` as a subprocess. The subprocess -cost no speed (about 100 ms against an IDE start of about 10 s), but each caller had to parse -its JSON and stderr back, and a harness failure and a compile error both arrived as an exit -code. +`sweep_attributes` call it rather than starting `tbbuild` as a subprocess. A subprocess +costs no speed (about 100 ms against an IDE start of about 10 s), but each caller would parse +its JSON and stderr back, and a harness failure and a compile error would both arrive as an +exit code. Running in one process gives four rules: @@ -331,7 +331,7 @@ Running in one process gives four rules: - **The name.** tb-ide already exports a `buildProject(c)` that builds an exe through an open connection, and the notes below mean that one; the new function is `compileProject`. -Two rules that came out of the move to a library: +Two further rules: - **Resolve a relative project path before comparing it.** The IDE is given the resolved path and echoes it back, and the wait loop compares that echo with the argument; with the @@ -418,8 +418,8 @@ Seven rules about the harness, each a comment in the file: The status does flap to UNAVAILABLE on the way, but the whole crash-restart cycle takes about 1.3 s against a 1 Hz sample, and after the third restart the IDE leaves the status at OPERATIONAL with the counters at zero --- byte-identical to a clean build. A 275-file - project read `0 errors, 0 warnings` and exit 0, reproducibly, while its quarters - reported 129, 0, 294 and 448 errors. The console is the record that sampling cannot miss, + project reads `0 errors, 0 warnings` and exit 0, reproducibly, while its quarters + report 129, 0, 294 and 448 errors. The console is the record that sampling cannot miss, because nothing removes an entry from it: `NATIVE EXCEPTION` and `restarting from MEMORY`, three times over, with a thread dump naming the file being parsed from the second crash on, and that file is what the exit-4 message reports. @@ -427,9 +427,9 @@ Seven rules about the harness, each a comment in the file: TRACE-MODE writes the thread dump that names the file, and the IDE switches that on in answer to the first `NATIVE EXCEPTION` and passes it to a compiler as it starts, so the restarted compiler's crash is the first to name one. A single re-read of the console after - the crash missed the name 31% of the time (BETA 983: the second crash comes 1.6 to 1.9 s - after the first on an idle machine, 1.8 to 3.0 s with four IDEs compiling at once). The poll - that replaced it, `awaitCrashName`, reads every 250 ms for up to 5 s, missed none and needed + the crash misses the name 31% of the time (BETA 983: the second crash comes 1.6 to 1.9 s + after the first on an idle machine, 1.8 to 3.0 s with four IDEs compiling at once). + `awaitCrashName` polls instead, every 250 ms for up to 5 s: it misses none and needs at most 3.25 s; the add-in lanes' `closeProject` uses it too. If a run ever has a second crash without a name, the third crash names the file as well, but under four lanes it came as late as 6.4 s, after the poll has given up. @@ -571,7 +571,7 @@ Rules it follows: (codegen) error`. The codegen case builds with `[LINKER] SUCCESS`: a `[RunAfterBuild]` Sub that shifts a `Single` (BUGS-TO-REPORT.md) adds `[BUILD] Executing 'DocSamples.Probe.Run'...` and the codegen line to the console, and nothing in the Sub runs. The type-library failure - came from IDEs sharing one temp folder; `launchIde` gives each its own (see + comes from IDEs sharing one temp folder; `launchIde` gives each its own (see WIP.ExamplesBuild.md). - **A callee's code-generation failure is erased by the probe's own `Debug.Cls`.** When the failing shift is in a procedure the probe calls, the codegen line naming that procedure @@ -798,7 +798,7 @@ be gone before anything is tidied, because `taskkill` only asks. a `tbrun` beside a lane run, another session's --- cannot each put back on their own. An IDE holds the recent list in memory and writes all of it back when it opens a project, so a run whose IDE is still open writes the other run's entries back after that run removed them (two -`ide_test` runs failed with exit 3 this way, each naming the other's lane folder). So the runs +`ide_test` runs fail with exit 3 this way, each naming the other's lane folder). So the runs share one record in `%TEMP%\tb-registry\`, read and written one run at a time under its `busy` folder: the first run to start records the recent list, the association and the theme (`base.json`); every run adds its folders and named projects (`-.json`); a run that @@ -852,9 +852,8 @@ end, it would point `.twinproj` files at a folder that has been deleted, and oth run `examples.bat` while add-in tests run copies, so the overlap is ordinary. `startTidy` notes whether the association it recorded names the temp folder, and if it did, `finishTidy` leaves the association as the IDEs set it and says so; the next IDE started from a real -install points it at that install. **A checkout without this rule still puts back what it -found**, so until every checkout has it, an association can be left naming a deleted copy, -and `.twinproj` files then open nothing until an IDE is started from a real install. Fix it +install points it at that install. An association found naming a deleted copy makes +`.twinproj` files open nothing until an IDE is started from a real install. Fix it by hand if you find it so. **The recent list is put back as it was found, not only swept.** On a real list the IDE @@ -928,9 +927,9 @@ process (next section) shows up. **`removeTree` retries the delete itself, because `rmSync` does not.** An IDE ended a moment ago still holds some of its files for a while. On Node 24.13 `rmSync`'s `maxRetries` and `retryDelay` do nothing here: it fails with `EPERM` within a millisecond on a folder -holding a file another process has open (measured). The registry tidy, a second or more of -PowerShell, used to sit between ending the IDE and deleting the copy and hid this; the lane -code deletes the copy the moment the IDE has gone. `removeTree` in `tb-ide-copy.mjs` retries +holding a file another process has open (measured). Nothing slow sits between ending the +IDE and deleting the copy to hide this: the lane code deletes the copy the moment the IDE +has gone. `removeTree` in `tb-ide-copy.mjs` retries for up to five seconds, and `removeIdeCopy` and the add-in runner both use it. **`loadedAddins(c)`** in `tb-ide-addins.mjs` is the check that the copy is what it claims to be. @@ -1018,7 +1017,7 @@ clicks Build, as `tbrun` does, and waits for the DEBUG CONSOLE. The wording is i compiler's strings: `[BUILD] Starting...`, then for a binary either `[LINKER] SUCCESS created output file ''` or one of about twenty failure lines --- `[LINKER] FAILED ...`, `[BUILD] FAILED ...`, `[BUILD] ERROR ...`, `[BUILD] failed`, `[LINKER] compilation (codegen) -error ...`. An output file another process held open gave `[LINKER] FAILED to create output +error ...`. An output file another process holds open gives `[LINKER] FAILED to create output file '...' (error code 32)`, then `LOCKED BY:` and a line naming the process, then `[BUILD] failed`. A package writes `[BUILD] Creating TWINPACK file ''` and then `[BUILD] successful.` (BETA 995), and its failures are `[BUILD] FAILED ...` and `[BUILD] failed` lines @@ -1067,9 +1066,7 @@ target, through `Lane.restartCompiler` and `Lane.setBuildTarget`, which also ref compile afterwards that crashed or has errors. `readCrash` in `tb-ide.mjs` says whether the compiler crashed, from the same console record `tbbuild` reads, and `awaitCrashName` waits for that record to name the file being parsed, which no -first crash does. Every call takes a connection from `attachIde`. Both of Stage 1's acceptance -scenarios were carried out with these calls alone, on a lab IDE with Samples 10 and 15 -built in; [WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 1 item 5, has what they did. +first crash does. Every call takes a connection from `attachIde`. **Input is real input; reading is from the page's data.** A click is the pointer moving to the element's centre, pressing and releasing, and a key press is the key-down and key-up a @@ -1079,7 +1076,7 @@ view draws only the rows that fit, and a tool window is a shadow root that `document.querySelector` cannot see into, so the calls read `toolWindowsById`, a list view's `dataNodes` and `window.editor` rather than what is drawn. -Eight rules, the first six learned on the samples: +Eight rules: - **A click scrolls its target into view, and checks what is at the point before it clicks.** Sample 10's tool window is taller than it is shown: its eleventh button has a @@ -1133,8 +1130,8 @@ Eight rules, the first six learned on the samples: anything else. When the IDE is still revealing lines 10 s later, `openFile`, `setCursor` and `select` throw, naming the file and the place, rather than go on while the cursor can still move; `afterReveal` itself returns `false`. The IDE's side of it is in BUGS-TO-REPORT.md. -- **A click checks where its press lands** (learned on the `assert` lane of - `ide-test.bat`). The page can change between the call that aims and the press, a few +- **A click checks where its press lands** (the `assert` lane of + `ide-test.bat` shows it). The page can change between the call that aims and the press, a few milliseconds to a hundred later on a busy page. In an editor the debugger has just opened, the error panel goes on moving after it is drawn: the file's decorations bring code lenses above the failing line and push it down 48 px. A click on Stop made as soon as @@ -1373,7 +1370,7 @@ probe lanes: lane need no change to the runner, and the second takes the same port once the first has been ended. -**Timing, BETA 995:** the twelve lanes take about two minutes at the default two at a time +**Timing, BETA 995:** the thirteen lanes take about two minutes at the default two at a time (the env lane is 16 s alone, the help lane 15 to 18 s, of which its sixteen cases are about 6 s). Typical lane costs (BETA 983): an add-in build about 10 s, a host IDE about 9 s, scenario 2 s; the keys lane 28 s (about 9 s of it pressing keys), panes 23 s, symbols and ideserver about 9 s each (they build nothing), appdata 18 s, arch 56 s (two builds diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index 96279ae78..dfb75fa18 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -5,8 +5,9 @@ add-in that shows the documentation for the symbol under the cursor, and the har tests IDE add-ins by machine, which the add-in is developed against. **Status.** Stages 1 to 3 stand. Stage 4, the add-in itself in [add-in/](add-in/), has -increments 1 to 4 built and tested: F1 to a page, the help pane, the compiler's hover to tell -which `Add`, and a name with no page shown by its declaration and `[Description]`. +increments 1 to 5 built and tested: F1 to a page, the help pane, the compiler's hover to tell +which `Add`, a name with no page shown by its declaration and `[Description]`, and offline +help served from an archive in the DLL. - `addin-test.bat` operates Samples 10 and 15 end to end and leaves the registry as it found it. - All fifteen of Stage 2's questions are answered. Fourteen are held by nine probe lanes that @@ -612,8 +613,7 @@ registry, `IDESettings` included through hashes, is identical around the run. Most probes are a small add-in plus a scenario. P5, P11 and P13 need only CDP and the file system. Record every answer in this file with the build number it was measured on. -All fifteen questions are answered, and the ten lanes (eight probe lanes and the two sample -lanes) pass together in about 2 minutes 21 seconds at two at a time. +All fifteen questions are answered. **A probe whose answer something else rests on becomes a lane**: its add-in in `test/addin/probes//`, its scenario beside the others, listed in `lanes.mjs`, with each @@ -810,9 +810,7 @@ harness about a second against opening a new IDE. embedded index. One page is shown in the help pane (increment 2); a miss says `No help for ''` through `ShowNotification`; an empty spot puts the focus in the pane's search box. Several pages fill the pane's results list, labelled - `Container.Name`, and leave the page as it was. Before the pane, they were a - `ShowMessageBox` with a button per page: `Add` on an unknown object gave twelve and - Cancel, and the IDE showed thirteen buttons without complaint. + `Container.Name`, and leave the page as it was. **The key is F1**: P1 and P2 do not rule it out. It fires wherever the focus is in the IDE's window. The one overlap is signature help: while it shows, F1 also expands @@ -845,8 +843,8 @@ harness about a second against opening a new IDE. - **Theme.** The pane's stylesheet, the resource `Resources/STYLESHEETS/pane.css` given to `ApplyCss`, uses the IDE theme's own custom properties (`--themeGeneralPanelBackColor`, `--themeToolWindowBodyForeColor`, ...). They are set on the IDE's document and - inherited by the shadow root, so the pane needs no code for a theme change. This - departs from the plan's light and dark stylesheets. The lane checks the background, + inherited by the shadow root, so the pane needs no code for a theme change and no + separate light and dark stylesheets. The lane checks the background, which the window does not otherwise inherit: the window already has the theme's text colour. - **The page's theme** is the IDE's: the frame's URL carries `?theme=` and @@ -1013,7 +1011,7 @@ harness about a second against opening a new IDE. With the cursor on `Add` in `Set w = Host.ToolWindows.Add(name, id)` this gives `Host.ToolWindows.Add`. A chain cut off on its left by `)` or by nothing, inside a `With`, -starts with a dot, which is dropped: `.Add`. Not handled in the first version: string +starts with a dot, which is dropped: `.Add`. Not handled: string literals and comments are read like code, so a name in a comment finds its page and a word in a string usually misses (`File.ReadText(CommentsToWhitespace)` can blank comments out if needed); lines joined with `_`; numbers, which miss the index. `GetSelectionInfo` counts diff --git a/WIP.Search.md b/WIP.Search.md index 970c088a5..2512731d0 100644 --- a/WIP.Search.md +++ b/WIP.Search.md @@ -11,9 +11,8 @@ Like WIP.md, this file is not rendered through tbdocs, so literal dashes are fin Everything needed to continue is in this file and in `eval/`. -**Where it stands.** The design below is shipped. The user's verdict: search -went from quite broken to at least semi-functional, and further work resumes -later. No decision is open. The candidates are under "Next"; the user picks. +**Where it stands.** The design below is shipped. No decision is open. The +candidates for further work are under "Next"; the user picks. **The eval now** (`node eval/search_quality.mjs`, ground truth `intent-5`, 10,327 queries): 98.2% at rank 1, 99.1% in the top 10. @@ -105,8 +104,7 @@ page for words it doesn't contain. So authors mark index entries by hand **The user's criteria**, which govern every decision here: - A reader either finds what they want or doesn't. A small regression is - still a miss, and being better than the old index is not the bar: that - index was nearly useless. + still a miss, and being better than an earlier index is not the bar. - Judge by what a reader typing the query wants. For a bare name the order is type names and language elements first, then members, then enum constants and similar, then prose. The order is a priority, not a filter: lower @@ -129,10 +127,10 @@ page for words it doesn't contain. So authors mark index entries by hand name-and-kind set leaves out `sub` and `member` rather than spelling a Sub as `method`). - Use Sonnet agents for mechanical and exploratory work. -- Review every agent's work before committing it. Agents have produced false +- Review every agent's work before committing it. Agents produce false explanations (see [X1t](#rejected-tier-specific-exact-fields-x1t)), a - lookbehind regex that would break the whole client on Safari before 16.4, - and a loading message that blanked on the second keystroke. + lookbehind regex that breaks the whole client on Safari before 16.4, + and a loading message that blanks on the second keystroke. **Tools.** - `node eval/search_quality.mjs --compare eval/search_baseline.json --worst 20` @@ -325,10 +323,6 @@ The table compares the configurations tried against the first ground truth. | BM25 `b` 0.5 / 0.25 on V2 + split | | | | 90.4 / 90.3% | .757 / .751 | | | | | **recommended**: V2 + symbols (50) + smart dot split | 6,713 | 1,143 KB | **88.7%** | **97.9%** | **.929** | 98.1% | 97.9% | 80% | -Against today, the recommended configuration made 6,590 queries better and 38 -worse, and left 1,384 unchanged. Its index build time in the browser was -about 1.1 s against 1.0 s. Query latency was unchanged at about 0.03 ms. - ### What the numbers showed - **h4 adds nothing.** From h3 to h4, no query changes rank. @@ -420,7 +414,7 @@ In the client's `update()`, for each token the tokenizer produces: - **Asterisk guard.** Drop tokens made only of `*`. If nothing is left, show no results. A search for `*` or `**` otherwise throws inside lunr's query engine and search stops working. (Trimming query tokens, see "What - shipped", now subsumes this.) + shipped", subsumes this.) ### 4. Both copies of the client @@ -453,12 +447,10 @@ drift apart silently. - **Stop words kept, so short common words crowd.** `VB` is a prefix of many `vbXxx` constant names in `names`; `Lock`, `Time$`, `Column`, `Index` - crowd the same way. Judged against the old index these looked like - regressions; that index was nearly useless, so its rank 1 was often not - what a reader wanted. Everything is judged by reader intent now; see - [Reader intent](#reader-intent-after-the-rollout). -- **Operators (24 symbols)** were unsearchable under this design; solved by - exact names. See [Future work](#future-work). + crowd the same way. Everything is judged by reader intent, not by the + rank an earlier index gave; see [Reader intent](#reader-intent-after-the-rollout). +- **Operators (24 symbols)** are unsearchable by their text under this design; + exact names find them. See [Future work](#future-work). - **Ground-truth bias.** Most queries are symbol names, so the numbers favour API lookup. The symbols field comes from the same data as the ground truth. The held-out check limits that concern, but does not remove it. The @@ -672,8 +664,8 @@ fair test. Done with field boosts, as the `primary` field, it took tier-order violations to zero (see "What shipped, second round"). Don't rebuild X1t without a new idea. -The agent that measured it blamed the CheckBox control's `names` field for -"listing every member". **That's false.** Both entries' `names` are the +**The CheckBox control's `names` field does not list every member**, as the +X1t measurement blamed it for doing. Both entries' `names` are the single word `CheckBox`. The real difference: the CheckBox page's top entry (`/tB/Packages/VB/CheckBox/`) has **1 character** of content, while `DTPicker › CheckBox` has 467 characters that mention "checkbox" several @@ -687,31 +679,29 @@ introduction. ### What shipped X1 + X2 + X3 as measured made 14 queries worse, so four corrections, each -measured, were made before shipping: +measured, are part of the shipped design: -1. **X3's required terms.** The experiment required each raw whitespace word, - neither lowercased nor split like the index, so every capitalised or - hyphenated phrase silently fell back to the plain query. Lowercased +1. **X3's required terms.** Requiring each raw whitespace word, neither + lowercased nor split like the index, makes every capitalised or + hyphenated phrase silently fall back to the plain query. Lowercased properly, `AddressOf operator` and `64-bit compilation` vanish, because - `operator*` can't match the stem `oper`. Shipped: each lunr token is + `operator*` can't match the stem `oper`. So each lunr token is required as its stem plus a trailing wildcard, with `usePipeline: false`. 2. **X1's boost.** A clause alone on a field has its boost divided back out (see "How lunr behaves here"), which is why boosts 50–1000 measured the - same. Shipped: field boost 50 on `exact`; the other clauses name every + same. Field boost 50 on `exact`; the other clauses name every field but `exact` (otherwise `node*` matches `nodes_`); `exact` is queried - only for a one-word query (`error handling` had lost its answer to - `Error`); and `exactName()` drops a trailing `$`, so `Format`/`Format$` - isn't marked down as a longer field. (The `$` rule changed again: see the - second round.) + only for a one-word query (`error handling` loses its answer to + `Error` otherwise). How `exactName()` spells `$` and other non-word + characters is under the second round. 3. **X2's boost is 5, not 20.** At 10 or 20 the `Folder` page outranks `Folder.Parent` and `Folder.Path`, which are documented on `FileSystemItem`. 4. **Query tokens are trimmed as the index trims them** (`lunr.trimmer`). This - fixed 8 `Xxx$` qualified queries that X2 had made worse, found every bare - `Xxx$` function that was unfindable, and subsumes the asterisk guard, + keeps 8 `Xxx$` qualified queries that X2 would make worse, finds every bare + `Xxx$` function, and subsumes the asterisk guard, since an all-`*` token trims to nothing. -Result: hit@1 89.0% → 93.5% on the intent ground truth, 0 queries worse and -418 better. Both client copies were checked in a browser against +Both client copies are checked in a browser against `eval/site_search.mjs`. `test/search.test.mjs`'s reader-intent guard covers the fields and the query. @@ -729,8 +719,8 @@ The remaining failures sorted into four mechanisms, measured one at a time: `builder/search.mjs`), and the client indexes them as exact names at field boost 1000. Boost 200 left 9 names out of tier order; 500 left 2; 1000 leaves none. -2. **`#If`, `#Const`, `#Else` lost their `#`** to lunr's trimmer and matched - the `If` function. `exactName()` now spells every non-word character as +2. **`#If`, `#Const`, `#Else` lose their `#`** to lunr's trimmer and match + the `If` function. `exactName()` spells every non-word character as `_` and its hex code (`#if` → `_23if_`), so they stay distinct. So do `Time$` against `Time` and `Error$` against the `Error` statement, and the 24 operators (`<>`, `&=`, `*`) become findable: the exact-name clause @@ -880,25 +870,25 @@ client's clauses by pattern and the replica by behaviour; mutations (boost 50, plain words completing in `qualified`, no pairs, the REQUIRED clause scoring in or dropped off `qualified`) each fail it. -The 7 stem collisions and the 2 entries with no `#shape` / `#timer` that were -left are fixed in [Fixed: stem twins](#fixed-stem-twins) and +The stem collisions and the entries with no `#shape` / `#timer` are handled in +[Fixed: stem twins](#fixed-stem-twins) and [Fixed: a member heading taken for the page title](#fixed-a-member-heading-taken-for-the-page-title). ### Fixed: a member heading taken for the page title -`extractSections` took *any* heading that read the same as the page's title, -with no prose before the first heading, for the title: that entry got the -page's URL and no prefix entry was made. On Shape and Timer the h1 is "Shape -class" / "Timer class", so `### Shape` and `### Timer`, which document the -Shape and Timer properties, became the page's entry, and no `#shape` / -`#timer` entry existed (`Shape.Shape`, `Timer.Timer` found nothing). Now only -the page's first heading can be the title. +`extractSections` takes only the page's first heading for the title, when it +reads the same as the page's title and has no prose before it: that entry gets +the page's URL and no prefix entry is made. A later heading that reads the same +is a member. On Shape and Timer the h1 is "Shape class" / "Timer class", so +`### Shape` and `### Timer`, which document the Shape and Timer properties, +keep their own entries (`#shape`, `#timer`), and `Shape.Shape` and +`Timer.Timer` are found. -It changed four pages' entries: Shape and Timer, and two prose pages whose h1 -differs from the title and whose second heading repeats it -(`Features/Standard-Library/New-Functions`, `Challenges/1`). Those two now -have an empty page entry beside the section, as 272 other pages already do -(every page whose h1 differs from its title). +The rule matters for four pages' entries: Shape and Timer, and two prose pages +whose h1 differs from the title and whose second heading repeats it +(`Features/Standard-Library/New-Functions`, `Challenges/1`). Those two have an +empty page entry beside the section, as 272 other pages do (every page whose h1 +differs from its title). ### Fixed: stem twins @@ -1264,7 +1254,7 @@ anchors outrank the Features pages for `inline assembly`. (`register COM dll`, `register dll`, `create dll`) and more variants: `inline initialization`, `field initialization`, `typedecl char[acter]`, `type char[acter]`. Capitals don't matter. -- Left to the user, now decided: `user defined types` (and `user types`) → +- Decided by the user: `user defined types` (and `user types`) → Type, then UDTs; `event handlers` → Handlers, then the Forms tutorial's handler step; `namespaces` → a new Glossary definition that links the relevant pages, then the Packages page; `twinpack` → Creating-TWINPACK, then @@ -1395,9 +1385,9 @@ through exact names that spell non-word characters (`<>` → `_3c_3e_`). The causes, which still explain why operators need exact names: - **Content.** `stripHtml` leaves `<` and `>` as the entities `<` and - `>`, so the literal character never reaches the index. (Decoded per - token now, see "Fixed: entities in the index"; the trimming below still - applies.) + `>`, so the literal character never reaches the index. (Entities are + decoded per token, see "Fixed: entities in the index"; the trimming below + still applies.) - **Trimming.** lunr's trimmer strips non-word characters from both ends of every token, in the index and in the query. An operator token trims to nothing. diff --git a/WIP.Typography.md b/WIP.Typography.md index 98aaafc4a..b9c75b22c 100644 --- a/WIP.Typography.md +++ b/WIP.Typography.md @@ -27,11 +27,12 @@ prefer that shape where the layout allows it. **A footnote back-link shows the hazard.** It is a bare inline `` holding a single U+21A9 with no padding, so its target is whatever box the font gives that glyph: 12.09 x 19 on Segoe UI, 12.09 x 15 on Liberation Sans, 15.97 x 17 on -Inter. **None of those meets 24 x 24.** It passed on axe's *spacing* exception ---- a 24px circle centred on the target clears its neighbours --- until Inter's -arrow, 3.9px wider, moved the centre far enough to intersect one. The fix sizes -the link outright (`display: inline-block; min-width/min-height: 24px`) rather -than restoring the clearance. +Inter. **None of those meets 24 x 24.** It can pass on axe's *spacing* exception +--- a 24px circle centred on the target clears its neighbours --- and a wider +glyph fails it: Inter's arrow is 3.9px wider than Segoe UI's, enough to move the +centre far enough to intersect a neighbour. The link is sized outright +(`display: inline-block; min-width/min-height: 24px`) rather than relying on the +clearance. Two rules follow. **Run the full sweep after any change that moves type metrics**: the thirteen-page sample stays clean through such a change and would diff --git a/WIP.md b/WIP.md index cc1f085c2..4c9f51cec 100644 --- a/WIP.md +++ b/WIP.md @@ -433,9 +433,9 @@ slots, the template projects and their stage sets, the batching and bisect-on-cr what actually collides inside one project, and what the first full run found. Two results from it concern the harness rather than the samples: `[RunAfterBuild]` is **one per project** (TB5114), which `check_run` has to be -designed around; and **`tbbuild` once reported a clean build on a project that crashed the -compiler** (fixed). Distrust any "it stopped changing, so it must be done" heuristic -against this compiler. +designed around; and **a project that crashes the compiler can look like a clean build** +(`tbbuild` reads the DEBUG CONSOLE to catch it and exits 4). Distrust any "it stopped +changing, so it must be done" heuristic against this compiler. ### A hung build times out From 0d7dc78aa2709781f436ae52ecb74186ce0d7b81 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 16:15:20 +0200 Subject: [PATCH 13/33] Two reproducibility defects of 997's builds, with reproducers: a build writes addresses from the compiler's own memory into the exe (scripts/probe_build_twice.mjs, with a VB6 side), and the IDE reverses the order of a project's files at each compiler restart, target switch and Save, which the code of a build follows (the restart-file-order lane) --- BUGS-TO-REPORT.md | 86 +++ .../build-writes-compiler-addresses.twinproj | Bin 0 -> 2214 bytes .../repro.json | 9 + .../src/Settings | 61 ++ .../src/Sources/Startup.twin | 6 + .../vb6/Module1.bas | 9 + .../vb6/Probe.vbp | 7 + bugs/restart-reverses-file-order/repro.json | 11 + .../restart-reverses-file-order.twinproj | Bin 0 -> 4856 bytes bugs/restart-reverses-file-order/src/Settings | 61 ++ .../src/Sources/ClsX.twin | 7 + .../src/Sources/ClsY.twin | 7 + .../src/Sources/FormF.tbform | 83 +++ .../src/Sources/FormF.twin | 13 + .../src/Sources/Startup.twin | 13 + docs/Documentation/Tools.md | 24 + scripts/lib/cli-cases.mjs | 2 + scripts/probe_build_twice.mjs | 576 ++++++++++++++++++ test/ide/lanes.mjs | 1 + test/ide/probes/restart-file-order/Settings | 61 ++ .../restart-file-order/Sources/ClsX.twin | 7 + .../restart-file-order/Sources/ClsY.twin | 7 + .../restart-file-order/Sources/FormF.tbform | 83 +++ .../restart-file-order/Sources/FormF.twin | 13 + .../restart-file-order/Sources/Startup.twin | 13 + test/ide/restart-file-order.test.mjs | 203 ++++++ 26 files changed, 1363 insertions(+) create mode 100644 bugs/build-writes-compiler-addresses/build-writes-compiler-addresses.twinproj create mode 100644 bugs/build-writes-compiler-addresses/repro.json create mode 100644 bugs/build-writes-compiler-addresses/src/Settings create mode 100644 bugs/build-writes-compiler-addresses/src/Sources/Startup.twin create mode 100644 bugs/build-writes-compiler-addresses/vb6/Module1.bas create mode 100644 bugs/build-writes-compiler-addresses/vb6/Probe.vbp create mode 100644 bugs/restart-reverses-file-order/repro.json create mode 100644 bugs/restart-reverses-file-order/restart-reverses-file-order.twinproj create mode 100644 bugs/restart-reverses-file-order/src/Settings create mode 100644 bugs/restart-reverses-file-order/src/Sources/ClsX.twin create mode 100644 bugs/restart-reverses-file-order/src/Sources/ClsY.twin create mode 100644 bugs/restart-reverses-file-order/src/Sources/FormF.tbform create mode 100644 bugs/restart-reverses-file-order/src/Sources/FormF.twin create mode 100644 bugs/restart-reverses-file-order/src/Sources/Startup.twin create mode 100644 scripts/probe_build_twice.mjs create mode 100644 test/ide/probes/restart-file-order/Settings create mode 100644 test/ide/probes/restart-file-order/Sources/ClsX.twin create mode 100644 test/ide/probes/restart-file-order/Sources/ClsY.twin create mode 100644 test/ide/probes/restart-file-order/Sources/FormF.tbform create mode 100644 test/ide/probes/restart-file-order/Sources/FormF.twin create mode 100644 test/ide/probes/restart-file-order/Sources/Startup.twin create mode 100644 test/ide/restart-file-order.test.mjs diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index a7e892b2b..d5115995a 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -1089,3 +1089,89 @@ In the default mode, MODERN, the console asks for the list after every key and a Severity: moderate. `Print`, `Call` and `Set` begin lines that are often typed in the console, and each becomes a line the console refuses. The only ways round it are Escape before the space and another IntelliSense mode. + +--- + +## A build writes addresses from the compiler's own memory into the exe + +**Describe the bug** +Two builds of one unchanged project are never byte for byte equal, and the difference is more than the PE time stamp and checksum. At the start of its `.data` section each exe holds a block of deflate-compressed data: a u32 compressed size, a u32 inflated size of 4,096, then a raw deflate stream. Decompressed, the blocks of two builds differ in two values that are addresses in the compiler process's memory: 4 bytes each in a win32 exe, and the low 5 or 6 bytes of a 64-bit value in a win64 exe. So the compiler writes values from its own memory into the exe, and every build gives a different file. + +Compression turns those 8 or 12 bytes into about 1,700 changed bytes of the stream, and changes its length by a few bytes. When the two lengths fall in different steps of the padding after the block (4 bytes in win32, 8 in win64), everything after the block moves by one step, and the exe differs in about 50 more bytes, 44 of them in `.text`, which holds addresses of that data. The padding is not cleared either: in win64 it holds leftover bytes, the top bytes of the two values. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `build-writes-compiler-addresses.twinproj` (attached as `build-writes-compiler-addresses.zip`), a console project with an empty `Sub Main`, with the build target win32. +2. Press Build and copy the exe to another folder. +3. Close the IDE, open the project again, press Build without changing anything, and copy that exe too. +4. Compare the two files. Besides the PE time stamp and checksum, they differ in the block at the start of `.data`. Inflate both streams and compare the two 4,096-byte results: two values differ, at offsets `0x4` and `0xbb8` in win32, and `0xc` and `0xbb8` in win64. The values of one win64 pair: + ``` + +0x00c 0x000001a5ea830968 / 0x0000024c70e7c6a8 + +0xbb8 0x000001a5e13684f0 / 0x0000024c6774d070 + ``` +5. Repeat with the build target win64. + +The documentation's build harness does steps 1 to 4 and prints each differing value: `node scripts/probe_build_twice.mjs`, with `--arch win64` for the win64 target. On BETA 997 the decompressed blocks differed in every pair: in 7 of 7 win32 pairs, in 6 or 7 bytes in two ranges, and in 2 of 2 win64 pairs, in 10 or 12 bytes in two ranges. Outside the time stamp, the checksum and the block, 5 of the 7 win32 pairs differed in nothing, 1 in a single byte after the block, and 1 in 54 bytes in 46 ranges, where the two streams fell in different steps of the padding. + +**Expected behavior** +Nothing from the compiler's own memory ends up in the exe, and the padding after the block is zero, so two builds of this project differ only in the PE time stamp and the checksum that follows from it. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +The values are addresses in the compiler process. After a build with the IDE left open, a read-only query of the memory map of the IDE's child processes (`VirtualQueryEx`; nothing was read from their memory) found both values of that build's exe in committed, private, read-write memory of the compiler process: `twinBASIC_win64_noDEP.exe` for a win64 build, and `twinBASIC_win32_noDEP.exe` for a win32 build. In the IDE's other child processes, both values fell in free address space. For win64 this is conclusive: only 3 of 200,000 random values below 2^47 fell in that process's committed memory. For win32 it is weaker, since 20,497 of 200,000 random 32-bit values did too. + +A single byte after the block also differs now and then: `1c` against `18` in win32, and `38` against `30` in win64. It reads as a count of 7 or 6 pointer-sized items; what it counts is not known. + +What VB6 does (the project attached as `build-writes-compiler-addresses-vb6.zip`, a Standard EXE whose `Sub Main` is empty, built twice in one folder): its exes are not identical either. Outside the time stamp and the checksum, they differ in the time stamps of the nine resource directories when the two builds fall in different seconds, in two random 16-byte identifiers, and in one 4-byte value that has the form of a heap address (from 0x00673ed8 to 0x009841a8 over eight builds; not confirmed against VB6's memory). But VB6 compresses nothing, so its one value changes 3 bytes, and in eight builds its exes never changed size or layout. + +BETA 995 gives the same kinds of difference, on win32 and on win64. + +What did not reproduce it: nothing in the project is needed. The console project with an empty `Sub Main` shows it, so no class, form or setting is involved. The harness stages the project into the same folders, with the same build path, before each build, so no path is what differs. + +Severity: low. No difference in a running program was looked for. Builds are not reproducible: two builds of one project cannot be compared, cached or signed by hash, and now and then the code itself differs. + + + +--- + +## The IDE reverses the order of a project's files at each compiler restart, target switch and Save, and the code of a build follows that order + +**Describe the bug** +The code of a built exe follows the order of the project's files, and the IDE reverses that order each time the compiler is restarted (the toolbar's restart button) and each time the build target is switched. Save writes the reversed order to the project file. So an unchanged project builds to exes whose code sits in another order: open it, press Build, press the restart button, press Build again, and the two exes hold the same procedures in a different order. + +In the attached project, the classes `ClsX` and `ClsY`, which nothing references, are laid out with `ClsX`'s code before `ClsY`'s in the first exe and after it in the second. A second restart and Build lays the third exe out as the first. The order was read by searching each exe's `.text` section for the four bytes of the constant that each procedure returns. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `restart-reverses-file-order.twinproj` (attached as `restart-reverses-file-order.zip`). It holds the module `Startup`, whose `Main` calls `FormF.F1()` and `S1()`, the form `FormF`, and the classes `ClsX` and `ClsY`, which nothing references. Each procedure returns a constant of its own: `X1` in `ClsX` returns `&H5A120001`, and `Y1` in `ClsY` returns `&H5A130001`. +2. Press Build. The project opens for win32, so the exe is `Build\RestartReversesFileOrder_win32.exe`. Copy it to `first.exe`. +3. Press the toolbar's restart button for the compiler, wait for the status bar to say `tB Services: OPERATIONAL`, press Build again, and copy the exe to `second.exe`. +4. Search each exe for the bytes `01 00 12 5A` (`X1`) and `01 00 13 5A` (`Y1`), and compare where they sit. In `first.exe` the bytes of `X1` come before those of `Y1`, and in `second.exe` they come after them. +5. Press the restart button and Build once more: the third exe is laid out as `first.exe`. +6. Close the IDE without saving, open the project again, switch the build target to win64 in the toolbar's build configuration box, and Build. In that exe `Y1` comes before `X1`, though the project file lists `ClsX.twin` before `ClsY.twin`. +7. Close the IDE, open the project again, and Save. The file Save writes holds the entries of every folder in the reverse order: its `Sources` lists `Startup.twin, FormF.twin, FormF.tbform, ClsY.twin, ClsX.twin`, where the attached file lists `ClsX.twin, ClsY.twin, FormF.tbform, FormF.twin, Startup.twin`. + +**Expected behavior** +The build does not depend on the order of the project's files, so two builds of one unchanged project hold their code in the same order. If the order matters, the IDE keeps it stable: a compiler restart, a switch of the build target and Save leave the order of the project's files as it was. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Checked on BETA 997 only, for win32 and win64. Every restart or switch reverses the order again, so a build is laid out in the order of the opened file after an even number of them, and in the reverse after an odd number. A new session builds in the order its file holds, so a Save also changes the first build of the next session. The file Save writes is reversed after none or two restarts and switches, and unchanged after one. + +What does not matter: two project files that differ only in the order of their entries build differently, with the same settings and the same revision numbers in both. Reversing only the top-level entries (`Settings`, `Sources`) changes nothing. + +In the minimal project, only the unreferenced classes that a referenced form keeps move. Without a referenced form the classes are not in the exe at all, and the order has no visible effect. A referenced class, or a referenced module function, does not keep them. Procedures that `Main` reaches stay in the order `Main` reaches them, and procedures of a module that nothing calls are not in the exe. + +No difference in behaviour is known, only in the layout: the two exes differ in `.text` and in about 340 bytes of `.rdata`. Whether a program can behave differently, for instance by creating objects in the order of the classes' files, was not tried. + +The same reversal makes the TB5074 of [#2492](https://github.com/twinbasic/twinbasic/issues/2492) come and go. That project lists `Probe.twin` before `ProbeDog.twin`, and in one session its compile reports TB5074 after the open, none after a switch to win64, TB5074 again after the switch back, and a restart does the same. A new session that opens the unchanged file starts with the error. + +Severity: low for a program, since no behavioural difference is known. A build depends on how many times the compiler was restarted in the session, so two exes of one unchanged project differ. A defect that depends on the order of the files, such as #2492, appears and disappears with each restart or switch, which makes it hard to reproduce and to test a fix for. + + diff --git a/bugs/build-writes-compiler-addresses/build-writes-compiler-addresses.twinproj b/bugs/build-writes-compiler-addresses/build-writes-compiler-addresses.twinproj new file mode 100644 index 0000000000000000000000000000000000000000..b7c10287599aae69f1d83a4b986ee7b457e68947 GIT binary patch literal 2214 zcmcguUvJ_@5GU!ME3`cJ?H*PrQtzZ1889T|%{JzQ8ZIbFL!}T^#$L!?ZLhVvK7tS* zr(dq0qBCnd!Pm6Cm)262-T6PinVrq+-@pCisP?*6t2r5q!nt6IN|(nei>R=}P*5qU z1ordyHEfS--@`hIQ!W$@Z?-ADU`yc(R+aqZI$@~mPH&4J7}u+IG8 zH-B9<@hWH6;pd7ugD;K1<%b&BTR|Qo%paK(+ley)#X?(IEKYW_+{Jr8ru$z{cD4u}p#Fi%1l_k!c4KG)?@`f2LZtf_>|^24 zKH!#wz6go;i&qs3sSE^5F;x`2qL5XXK@HD|O>~T8qkE3{WwVi$;4x7TlmtZ|{vZk8 zBqXLW7hA#-#WkBg(yBB1i2aC3PULMD{3(&q3YILyD0YLPb7qdlrqMeyPYtU#vJKmG zJ;NJWeao@Eq0{Sql5YYSq3-8bfa|Qi#}s;qsh6S=bVUVC01e>YLCx#fAoY5y2-7sp zu4(m512)CtmOCVwrZvdJ1f3|*S|;WT$1>i_3y;{D;<0WwjTdlJz^I1^wcPW?V$PC~ zZ{%Wey_NIJ`M_E%qzZXNyKNOM4|N9COFD|iz3z)TavLvsG|{N%CQEA{slcw9`X9Ie zo82PplFSlKHZhsK=2f8?_f6-lJ2H)tYdcWEv1beiy^hi84Ew{8Wm~7+vnOcS(?lyK z3^*)X1Ce6^76CJxPHxHoO91cO%js-#;a!oL?fzuH^X>_b5XZirxWpj04#9B==q_2r z)Rq)3$eS@2v3y&adq(__lH>VA!FeNL9$+Xp^T~Vd#3%zO#STHebxLZ1obFH1FZ-Yb zw7~gOa*1;l{ww;CVRgVxuk&{$a67~9*y#<8f#uqUH5xjGW1XHFcE4|S$JUuQcHRG_ z1a_K|b;Or`M1shdFf5ihI7?vuBjmyBJMGj(pT#6CeSTgsI#txC|5}XvIzU10QS<4A zki~m*o8Kv@nzsDR&!4`_92V%U@Ilh`uei9s1RJ}*`)o|TMsaM56^53 z*OcPzzAGeHvf>BF1Cw|65VF3?Es-UP#k8GC`g6hp9@6pw$^VPkf(5*v&Bi`|R{sRR CAx!uH literal 0 HcmV?d00001 diff --git a/bugs/build-writes-compiler-addresses/repro.json b/bugs/build-writes-compiler-addresses/repro.json new file mode 100644 index 000000000..3e6cc71bd --- /dev/null +++ b/bugs/build-writes-compiler-addresses/repro.json @@ -0,0 +1,9 @@ +{ + "mode": "probe", + "probe": ["scripts/probe_build_twice.mjs"], + "expect": { + "exit": 1, + "output": ["^the decompressed startup blocks of two builds differ in [1-9]\\d* bytes, in [1-9]\\d* ranges? \\(win32\\)$"] + }, + "steps": "1. Unzip build-writes-compiler-addresses.zip and open the project in the IDE, with the build target win32.\n2. Press Build and copy the exe it writes to another folder.\n3. Close the IDE, open the project again, press Build without changing anything, and copy that exe too.\n4. Compare the two files byte for byte. Besides the PE time stamp and checksum they differ in a deflate-compressed block at the start of .data: a u32 compressed size, a u32 inflated size of 4,096 and a raw deflate stream. Inflate both streams and compare them: two values differ, and they have the form of heap addresses (4 bytes each in a win32 exe, the low 6 bytes of a 64-bit value in a win64 exe).\n5. Control: build the vb6/ project twice with VB6 (scripts/probe_build_twice.mjs --vb6). Its exes differ in the time stamp, the checksum, two random 16-byte identifiers and one 4-byte value; they have no such block.\nscripts/probe_build_twice.mjs does steps 1 to 4 in two IDEs, for win32 or with --arch win64, and prints each differing value. On BETA 997, 6 and 7 of the 4,096 decompressed bytes differed in two ranges for win32, and 12 and 10 for win64." +} diff --git a/bugs/build-writes-compiler-addresses/src/Settings b/bugs/build-writes-compiler-addresses/src/Settings new file mode 100644 index 000000000..033c78f43 --- /dev/null +++ b/bugs/build-writes-compiler-addresses/src/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "BuildWritesCompilerAddresses", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: A build writes addresses from the compiler's own memory into the exe", + "project.exportPathIsV2": true, + "project.id": "{69B80DF0-3807-43DA-A0CE-ED454BAE9B33}", + "project.name": "BuildWritesCompilerAddresses", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/build-writes-compiler-addresses/src/Sources/Startup.twin b/bugs/build-writes-compiler-addresses/src/Sources/Startup.twin new file mode 100644 index 000000000..20233b3a9 --- /dev/null +++ b/bugs/build-writes-compiler-addresses/src/Sources/Startup.twin @@ -0,0 +1,6 @@ +Module Startup + + Public Sub Main() + End Sub + +End Module diff --git a/bugs/build-writes-compiler-addresses/vb6/Module1.bas b/bugs/build-writes-compiler-addresses/vb6/Module1.bas new file mode 100644 index 000000000..066257d0a --- /dev/null +++ b/bugs/build-writes-compiler-addresses/vb6/Module1.bas @@ -0,0 +1,9 @@ +Attribute VB_Name = "Module1" +Option Explicit + +' The VB6 side of a bug reproducer: what VB6 does where the report says twinBASIC differs. +' Probe.vbp builds Probe.exe, whose Sub Main does nothing and writes nothing: the two builds +' of it are what is compared (node scripts/probe_build_twice.mjs --vb6). + +Sub Main() +End Sub diff --git a/bugs/build-writes-compiler-addresses/vb6/Probe.vbp b/bugs/build-writes-compiler-addresses/vb6/Probe.vbp new file mode 100644 index 000000000..4d210b0e8 --- /dev/null +++ b/bugs/build-writes-compiler-addresses/vb6/Probe.vbp @@ -0,0 +1,7 @@ +Type=Exe +Module=Module1; Module1.bas +Startup="Sub Main" +ExeName32="Probe.exe" +Name="Probe" +CompilationType=0 +OptimizationType=0 diff --git a/bugs/restart-reverses-file-order/repro.json b/bugs/restart-reverses-file-order/repro.json new file mode 100644 index 000000000..a575b2521 --- /dev/null +++ b/bugs/restart-reverses-file-order/repro.json @@ -0,0 +1,11 @@ +{ + "mode": "lane", + "lane": "ide:restart-file-order", + "tests": [ + "after a compiler restart, a build lays the code out in the reverse order of the build before it", + "after a second restart, a build lays the code out in the order of the first build again", + "Save writes the project's files in the reverse of the order they were opened in", + "after a switch to win64, a build lays the code out in the reverse order of the build before it" + ], + "steps": "1. Open restart-reverses-file-order.twinproj in the twinBASIC IDE, which opens it for win32, and press Build. Copy Build\\RestartReversesFileOrder_win32.exe to first.exe.\n2. Press the toolbar's restart button for the compiler, wait for the status bar to say OPERATIONAL, press Build again, and copy the exe to second.exe.\n3. Find where the code of the classes ClsX and ClsY sits in each exe. Each procedure returns a constant of its own: X1 in ClsX returns &H5A120001, the bytes 01 00 12 5A, and Y1 in ClsY returns &H5A130001, the bytes 01 00 13 5A. Search each exe for the two byte sequences and compare their offsets.\n4. Expected: the two exes of one unchanged project hold their code in the same order. Actual: in first.exe the bytes of X1 come before those of Y1, and in second.exe after them.\n5. Press the restart button and build once more: the order of first.exe returns. A switch of the build target in the toolbar's build configuration box, and Save, reverse the order in the same way: a build after the switch to win64 has the bytes of Y1 before those of X1, and the file Save writes holds the project's files in the reverse of the order they were opened in.\nAsserted on BETA 997 by `ide-test.bat --only restart-file-order` (the project is test/ide/probes/restart-file-order)." +} diff --git a/bugs/restart-reverses-file-order/restart-reverses-file-order.twinproj b/bugs/restart-reverses-file-order/restart-reverses-file-order.twinproj new file mode 100644 index 0000000000000000000000000000000000000000..99e508445a1ed4181cf53afe8c019774e6dce7b0 GIT binary patch literal 4856 zcmcIo-EQN$5zg7uKfBFkfnM~25Eg9~T?O(_oOpqAVaxW$IB{ac-eebX5YRGh^F$T} zk;+elJWk)KFVLGlMQ2FLlAY}7SrqkRWow3<8GbVye$1c$@%w*&xAUi+ot<+g6;0GR zyJLxDvd2SqmdscJs{HdC`1xk%kMQiTVv#5|8$ZVEgijNia3=GCJ3GI|mp41R@EmX% zurQ<%6Y0hnGzVjT2hSl5Zs}sv#BYtd1YZ!HNRwbw|HkNX*yyG9dyL?_9h~il^7FpB z=aHU)v+%Qo|9(iNB&gpl@0JOChUqlq0qLbtpty+0=gQtY(vsvvL<=(lM}4l4AIM*h z4_cL~=Xn*Q=ti^kJT!;z+QDua{jFp9@?T>4^0ipLyc)}wbu6`Sh~*CuQBNeR-oBd7 z;d=v`JHYg+TzV>(N=_@hr;l>96!$-}$El#n%uktgK9D&LC3~-j#4)1@jRFQ@=Wg24 z@3om@w5by3Y?jdbjY)0DxGzGHfYhy4>WB5CTBBCeQ`!PcZ=@bWYjMQ?PN(pqwLXn8 zHRwyPz)C|N`yx__2r)X9q$W5kYm0}Xv;*M`#LSxaMf^Bqkuij-qH7UyyAsq~@|jwK zrHT%v8(~`04UTP8OLB@q!ND1)G|q5SSf~401W+piU9wRM`)`UQNpsNRR47>!qbHlZI=+3{i0I%~&_FO~`DK*Ln;T=rCb-oNZRx37exOGvkCuw|V06?Nq=r^?ZCAi&z65rD3RrgHGRH@~zwq z=tDbIN;_Z248nET#u-Smda(i?y)utS+w*v|H4i!`+g$7bMMJ?O#c&J3w(GDtO+#B! z198XnmMMUml}%ySoI(XDP8A!lDBaxEXD8iGBH|n@E6DLWi)p~sBY3aaEDdSMl)|d9 zDY1gwNI`H+!#O}`!5{KKrAcwB5p-fc3SbvR3$U8k;yKpGSlHa=avaOnaMllOU|PIU z2wIZIHNNMuG-aSQPzT`8WK9dQgybfnCHA8+mA6xh8CciB2oKUv@lZ&f^{Kn56^??0 zWsPP`r#%s*vIPxt#6lKemF6oMQ(W=74vPW7?}3n8!`DqEc!Z4rQ(e=eF1X~_I|~tB zE~%2^C70`B%-ybaxZ^eg$`Hu5%TfNb$UZc|Vjt;y-GbTU^QQTQ} z{Xr=8DxIfVt<|abYDaFP(QLYvir;hFho0|xUaRGK4X;yc)}Ebm>A8Gui2{Dv!+D#v z{0mf9&}f5%YjF1*=iT*g`3i^U{ddp#CC%5&gqAsDunPF<&%l)7MB05uL`Lb9oU;49 zcRA4kQKsFdOubiWN-v`+Uhf-eirzYD{E{{O6R?0CoQ90pFIGmLw3X7z7DSl-RD{6L>C|BJ!Rq&_g6-MDN&p{R@o-z!r^7jR~2H zFckL^9D+^laU*8-fEOf0sxL395Vq|(>!sV~Q1_eNO8cNO8~ zm1iG*_6tRxS8o^!S8~EYPqNKBixNl!!2@okD^&MJs4jq|hM+k9hsng8BXTksJ<7|o z%SL@Nk!mJFR^3`ePeehT4^XHW>C#y60rr@_%f7>|PVRzqVVWzE50o(I#jbAf56M7#Oh* z$R4&Z`L2MOfw>?sAZ7x*?Mi4G7{>Urf2uvWnRh#?2;@cE3N2i^3BYWd_6$S|?Upoy zb^~zulLIgmO{Hs{|7qU zYRMuLQyLO|<`Xf+o|+-KF6n~cf5aL*e$)>2k!-u{x4^Fn;LFPX8vMHC%O$)_t=p8e zr_Y7iGmvRCnyaySrtz790*WX*axu*HsC$}3c!a@h+)WNk5~1mty(KK+7Nh%*xyJ!X y6u?GcwAJ%yF7mg6wX18OP3S2}BgI#2AMQk-B96T}J59`Ar;rb0>nlHNiuE7oiXdPB literal 0 HcmV?d00001 diff --git a/bugs/restart-reverses-file-order/src/Settings b/bugs/restart-reverses-file-order/src/Settings new file mode 100644 index 000000000..c8a5959f9 --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "RestartReversesFileOrder", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: The IDE reverses the order of a project's files at each compiler restart, target switch and Save, and the code of a build follows that order", + "project.exportPathIsV2": true, + "project.id": "{4C9E1B7A-5D32-4F68-A0B1-93E7D2C45A18}", + "project.name": "RestartReversesFileOrder", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/restart-reverses-file-order/src/Sources/ClsX.twin b/bugs/restart-reverses-file-order/src/Sources/ClsX.twin new file mode 100644 index 000000000..2d026ed3f --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Sources/ClsX.twin @@ -0,0 +1,7 @@ +Class ClsX + + Public Function X1() As Long + X1 = &H5A120001 + End Function + +End Class diff --git a/bugs/restart-reverses-file-order/src/Sources/ClsY.twin b/bugs/restart-reverses-file-order/src/Sources/ClsY.twin new file mode 100644 index 000000000..9f812fddb --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Sources/ClsY.twin @@ -0,0 +1,7 @@ +Class ClsY + + Public Function Y1() As Long + Y1 = &H5A130001 + End Function + +End Class diff --git a/bugs/restart-reverses-file-order/src/Sources/FormF.tbform b/bugs/restart-reverses-file-order/src/Sources/FormF.tbform new file mode 100644 index 000000000..48caf0a3d --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Sources/FormF.tbform @@ -0,0 +1,83 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": -2147483633, + "BorderStyle": "vbSizable", + "Caption": "FormF", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA02}", + "HasDC": true, + "Height": 300, + "HelpContextID": 0, + "Icon": [], + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": [], + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "FormF", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": [], + "PaletteMode": "vbPaletteModeHalftone", + "Picture": [], + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 4500, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 4500, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 300, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": false, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 2061908507, + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/bugs/restart-reverses-file-order/src/Sources/FormF.twin b/bugs/restart-reverses-file-order/src/Sources/FormF.twin new file mode 100644 index 000000000..fe7fa140b --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Sources/FormF.twin @@ -0,0 +1,13 @@ +[Description("")] +[FormDesignerId("EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA02")] +[PredeclaredId] +Class FormF + + Sub New() + End Sub + + Public Function F1() As Long + F1 = &H5A0F0001 + End Function + +End Class diff --git a/bugs/restart-reverses-file-order/src/Sources/Startup.twin b/bugs/restart-reverses-file-order/src/Sources/Startup.twin new file mode 100644 index 000000000..7e66b5aff --- /dev/null +++ b/bugs/restart-reverses-file-order/src/Sources/Startup.twin @@ -0,0 +1,13 @@ +Module Startup + + Public Sub Main() + Dim t As Long + t = FormF.F1() + t = t + S1() + End Sub + + Public Function S1() As Long + S1 = &H5A110001 + End Function + +End Module diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 9472b574a..f1ee49472 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -1184,6 +1184,30 @@ The output is one `round : failed` line for each round, the last console Exit codes: **0** no build failed to write the type library; **1** at least one did, so the defect is there; **2** a refused command line, no IDE, no free ports, an IDE that did not start, a project that did not compile, a build that failed in some other way, or a crash. +### probe_build_twice.mjs +{: #probe-build-twice } + + node scripts/probe_build_twice.mjs [--arch win32|win64] [--ide ] [--port N] + [--timeout S] [--keep-files ] + node scripts/probe_build_twice.mjs --vb6 [--timeout S] [--keep-files ] + +Builds the project of `bugs/build-writes-compiler-addresses/` twice and compares the two exes. It is the measurement behind the entry of `BUGS-TO-REPORT.md` whose reproducer that is: two builds of one unchanged project are not byte for byte equal, and what differs is more than the PE time stamp and checksum. Each exe holds, at the start of its `.data` section, a block of deflate-compressed data: a u32 compressed size, a u32 inflated size of 4,096 and a raw deflate stream. Inflated, the blocks of two builds differ in two values that have the form of heap addresses: 4 bytes each in a win32 exe, and the low 6 bytes of a 64-bit value in a win64 exe. Like [`tbbuild.mjs`](#tbbuild), it needs a twinBASIC install and Windows, and it is outside every gate and outside CI. + +Each build is made in an IDE of its own, one after the other on one port, because an IDE reused for a second project wedges. Before each build the project is staged again, packed into a folder under `%TEMP%\tbprobe-build-twice\\` with an explicit build path, in the same folders both times, so that no path can be what differs. The exe is copied out as soon as the build is done. + +The output has these parts, in order: the two sizes, and whether the section tables are the same; the PE time stamp and checksum of each file, which the linker sets and the comparison leaves out; the startup block, found by its header (a u32 compressed size, then a u32 inflated size that is a multiple of 1,024, at a 4-byte step of `.data`, then a stream that inflates), with the sizes of the two streams and their padding to the pointer size of the target; the bytes that differ outside the time stamp, the checksum and the block, by section, one line for each range with its bytes in both files; and the decompressed bytes of the block that differ, each range read as the little-endian value of the target's pointer size that holds it. The two blocks are lined up on the end of their padding, so that data which moved by one step because a stream is a few bytes longer is not counted as a difference, and the padding is compared on its own, aligned at its end. The last line is the summary, `the decompressed startup blocks of two builds differ in bytes, in ranges ()`, or `the decompressed startup blocks of two builds are equal ()`. A stream that does not inflate to exactly the size its header states, and an exe with no such block, end the probe with exit 2. + +| Flag | Effect | +|---|---| +| `--arch ` | `win32` or `win64`, the target to build for. Default `win32`. | +| `--ide ` | Path to `twinBASIC.exe`, found as for `tbbuild`. | +| `--port ` | The first DevTools port to try. Default 9800. The IDE takes the first free port from it. | +| `--timeout ` | The wait for a compile to settle, and again for a build. Default 180. | +| `--keep-files ` | Copy the two exes into ``, which is made if it is missing, as `-1.exe` and `-2.exe`. Nothing already in the folder is removed. | +| `--vb6` | Build the `vb6/` project of the reproducer twice with VB6 in place of the twinBASIC project, and print every range that differs outside the time stamp and the checksum, by section. A VB6 exe has no deflate-compressed block. VB6 is found as for [`vb6run.mjs`](#vb6run) (`VB6_EXE`, else the standard install folders) and is started only through `scripts/lib/vb6.mjs`, never from a shell. The project is built with Unattended Execution, as `bug_repro.mjs vb6` builds it. Both builds are made in one folder, because VB6 stores the folder's name in the exe and two folders would show as a difference of their own. `--arch`, `--ide` and `--port` do not apply, and are refused with it. | + +Exit codes: **0** the compared bytes are equal, the two decompressed blocks or, with `--vb6`, the two files outside the time stamp and the checksum; **1** they differ, so the defect is there; **2** a refused command line, no IDE or no VB6, no free port, an IDE that did not start, a project that did not compile or build, a block that does not inflate to its stated size or is not there, or a crash. + ### vb6run.mjs {: #vb6run } diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs index c70eba6bc..281c58b5c 100644 --- a/scripts/lib/cli-cases.mjs +++ b/scripts/lib/cli-cases.mjs @@ -379,6 +379,7 @@ const HELP_TOOLS = { "scripts/gen_attribute_probes.mjs": "Generate a twinBASIC probe project for Reference/Attributes.md applicability.\n", "scripts/impexp.mjs": "Usage:\n", "scripts/pick_a11y_sample.mjs": null, + "scripts/probe_build_twice.mjs": null, "scripts/probe_shared_temp.mjs": null, "scripts/survey_tooling.mjs": null, "scripts/sweep_a11y.mjs": null, @@ -455,6 +456,7 @@ const REFUSALS = { "scripts/gen_attribute_probes.mjs": [null], "scripts/impexp.mjs": [null, { prefix: "ERROR: " }], "scripts/pick_a11y_sample.mjs": ["sweep"], + "scripts/probe_build_twice.mjs": ["ide"], "scripts/probe_shared_temp.mjs": ["ide"], "scripts/survey_tooling.mjs": ["root"], "scripts/sweep_a11y.mjs": ["out"], diff --git a/scripts/probe_build_twice.mjs b/scripts/probe_build_twice.mjs new file mode 100644 index 000000000..d0eff1c66 --- /dev/null +++ b/scripts/probe_build_twice.mjs @@ -0,0 +1,576 @@ +#!/usr/bin/env node +// Build one project twice and compare the two exes. +// +// node scripts/probe_build_twice.mjs [options] +// +// The defect is the entry of BUGS-TO-REPORT.md whose reproducer is +// bugs/build-writes-compiler-addresses/: two builds of one unchanged project are +// not byte for byte equal, and what differs is more than the PE time stamp and +// checksum. Each exe holds, at the start of its .data section, a block of +// deflate-compressed data: a u32 compressed size, a u32 inflated size of 4,096 +// and a raw deflate stream. Inflated, the blocks of two builds differ in two +// values that have the form of heap addresses: 4 bytes each in a win32 exe, the +// low 6 bytes of a 64-bit value in a win64 exe. In a win64 exe the padding after +// the block also holds two stale bytes. In a win32 exe the two compressed +// lengths sometimes fall in different 4-byte steps, which moves everything after +// the block by 4 and changes about 44 bytes of .text. +// +// Each build is made in an IDE of its own (an IDE reused for a second project +// wedges), on one port, from a project staged again before each build into the +// same folders with the same build path, so that no path can be what differs. The +// exe is copied out as soon as the build is done. --vb6 does the same with VB6, +// for the reproducer's vb6/ project, whose exe has no such block, and prints every +// range of the two files that differs outside the time stamp and the checksum. +// +// Exit codes: see USAGE. +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; +import { + choiceOption, + die, + exitOnCrash, + numberOption, + parseCli, + printHelpAndExit, + refuseTogether, + withUsageError, +} from "../lib/cli.mjs"; +import { compileProject } from "./lib/tb-build.mjs"; +import { TARGETS } from "./lib/tb-ide.mjs"; +import { buildNumber, findIde } from "./lib/tb-install.mjs"; +import { claimPorts } from "./lib/tb-ports.mjs"; +import { laneProjectId, stageProject } from "./lib/tb-project.mjs"; +import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; +import { + NO_VB6, + REPRO_PROJECT, + decodeAnsi, + encodeAnsi, + findVb6, + hasVisibleComponent, + make, + reproFiles, + reproProblem, + unattended, +} from "./lib/vb6.mjs"; + +let tidy = null; +let work = null; +function cleanUp() { + finishTidy(tidy); + tidy = null; + if (work) { + try { + rmSync(work, { recursive: true, force: true }); + } catch { + // a file still held open; the next run on this port empties the folder + } + work = null; + } +} +exitOnCrash(cleanUp); + +const DEFAULT_PORT = 9800; + +const USAGE = `usage: node scripts/probe_build_twice.mjs [--arch win32|win64] [--ide ] [--port N] [--timeout S] [--keep-files ] [--vb6] [-h, --help] + +Builds the project of bugs/build-writes-compiler-addresses/ twice, each time in +an IDE of its own, and compares the two exes. Outside the PE time stamp and the +checksum, two builds of one unchanged project should be byte for byte equal. They +are not: each exe holds, at the start of .data, a deflate-compressed block (a u32 +compressed size, a u32 inflated size and a raw stream) whose decompressed bytes +differ in two values that have the form of heap addresses. + +The probe prints the two sizes; the bytes that differ outside the time stamp, the +checksum and the block, by section; the block, found by its header, with the number +of its decompressed bytes that differ and each differing value; and last a summary +line, \`the decompressed startup blocks of two builds differ in bytes, in +ranges ()\`, or \`the decompressed startup blocks of two builds are equal +()\`. A stream that does not inflate to exactly the size its header states is +reported, and ends the probe. + + --arch win32 or win64, the target to build for (default win32) + --ide twinBASIC.exe (default: $TB_IDE, else the newest + twinBASIC_IDE_BETA_* on the Desktop) + --port DevTools port of the IDE (default ${DEFAULT_PORT}); the first free port from + it is used + --timeout give up waiting for a compile, and again for a build (default 180) + --keep-files copy the two exes into , made if it is missing, as + -1.exe and -2.exe (vb6-1.exe and vb6-2.exe with --vb6) + --vb6 build the vb6/ project of the reproducer twice with VB6 + (VB6.EXE: $VB6_EXE, else the standard install folders) in place + of the twinBASIC project, and print every range that differs + outside the time stamp and the checksum, by section; a VB6 exe + has no such block. --arch, --ide and --port do not apply + -h, --help print this text and exit + +Exit codes: + 0 the compared bytes are equal: the two decompressed blocks, or with --vb6 the + two files outside the time stamp and the checksum + 1 they differ: the defect is there + 2 the probe could not do its job: a refused command line, no IDE or no VB6, no + free port, an IDE that did not start, a project that did not compile or + build, a block that does not inflate to its stated size or is not there, or + a crash`; + +const usageError = { format: (err) => `${err.message}\n${USAGE}` }; + +const { values } = withUsageError( + () => + parseCli(process.argv.slice(2), { + options: { + arch: { type: "string" }, + ide: { type: "string" }, + port: { type: "string" }, + timeout: { type: "string" }, + "keep-files": { type: "string" }, + vb6: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + stopAt: ["help"], + }), + usageError, +); +if (values.help) printHelpAndExit(USAGE); + +const { arch, port, timeout } = withUsageError(() => { + for (const name of ["arch", "ide", "port"]) refuseTogether(values, ["vb6", name]); + return { + arch: choiceOption(values.arch ?? TARGETS[0], { option: "--arch", choices: TARGETS }), + port: numberOption(values.port ?? String(DEFAULT_PORT), { option: "--port", integer: true, min: 1, max: 65535 }), + timeout: numberOption(values.timeout ?? "180", { option: "--timeout", above: 0 }) * 1000, + }; +}, usageError); +const keepFiles = values.keepFiles ? path.resolve(values.keepFiles) : null; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const REPRODUCER = path.join(REPO, "bugs", "build-writes-compiler-addresses"); +const SOURCE = path.join(REPRODUCER, "src"); +const VB6_SOURCE = path.join(REPRODUCER, "vb6"); + +/** A fault of the probe's own job, not a finding: it ends the run with exit 2. */ +class Fail extends Error {} + +// ----------------------------------------------------------------- the files + +const thousands = (n) => n.toLocaleString("en-US"); +const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? "" : "s"}`; +const hexOf = (n) => n.toString(16); + +/** The bytes as hex, in file order, cut after `max` bytes. */ +function bytesHex(buf, start, length, max = 16) { + const shown = buf.subarray(start, start + Math.min(length, max)).toString("hex"); + return length > max ? `${shown}..(+${length - max})` : shown; +} + +/** + * The ranges [start, end) of bytes in which `a` and `b` differ, over their common + * length. Two differences with at most `gap` equal bytes between them are one range. + */ +function diffRanges(a, b, gap = 3) { + const common = Math.min(a.length, b.length); + const ranges = []; + let start = -1; + let last = -1; + for (let i = 0; i < common; i++) { + if (a[i] === b[i]) continue; + if (start >= 0 && i - last - 1 > gap) { + ranges.push([start, last + 1]); + start = -1; + } + if (start < 0) start = i; + last = i; + } + if (start >= 0) ranges.push([start, last + 1]); + return ranges; +} + +/** + * The parts of a PE file the comparison needs: where the COFF time stamp and the + * optional header's checksum are, and the sections' names and raw data. + * + * @throws {Fail} when the file is not a PE file + */ +function parsePe(buf, which) { + const bad = (why) => new Fail(`${which} is not a PE file: ${why}`); + if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) throw bad("no MZ header"); + const pe = buf.readUInt32LE(0x3c); + if (pe + 24 > buf.length || buf.readUInt32LE(pe) !== 0x4550) throw bad("no PE signature"); + const file = pe + 4; + const count = buf.readUInt16LE(file + 2); + const optional = file + 20; + const table = optional + buf.readUInt16LE(file + 16); + if (!count || table + count * 40 > buf.length) throw bad("the section table is missing or cut short"); + const sections = []; + for (let i = 0; i < count; i++) { + const at = table + i * 40; + const raw = buf.readUInt32LE(at + 20); + sections.push({ + name: buf.toString("latin1", at, at + 8).split("\0")[0], + raw, + rawSize: Math.max(0, Math.min(buf.readUInt32LE(at + 16), buf.length - raw)), + }); + } + return { plus: buf.readUInt16LE(optional) === 0x20b, stamp: file + 4, checksum: optional + 64, sections }; +} + +// The block is at a 4-byte step of .data. A header is taken for one only when its +// inflated size is a multiple of 1,024 and no more than 1 MB, and the stream inflates. +const MAX_INFLATED = 1 << 20; + +/** + * The startup block of an exe: a u32 compressed size, a u32 inflated size and a raw + * deflate stream, padded to the pointer size of the target. It is found by its + * header, at the first 4-byte step of .data where one inflates. + * + * @throws {Fail} when there is none, or a stream does not inflate to exactly the size its header states + */ +function findBlock(buf, pe, which) { + const ptr = pe.plus ? 8 : 4; + const data = pe.sections.find((s) => s.name === ".data"); + if (!data) throw new Fail(`${which}: the exe has no .data section`); + const end = data.raw + data.rawSize; + for (let at = data.raw; at + 12 <= end; at += 4) { + const stream = buf.readUInt32LE(at); + const size = buf.readUInt32LE(at + 4); + if (stream < 16 || size < 1024 || size > MAX_INFLATED || size % 1024 || stream >= size || at + 8 + stream > end) { + continue; + } + let inflated; + try { + inflated = zlib.inflateRawSync(buf.subarray(at + 8, at + 8 + stream)); + } catch { + continue; + } + const rel = at - data.raw; + if (inflated.length !== size) { + throw new Fail( + `${which}: the header at .data+0x${hexOf(rel)} says the stream of ${thousands(stream)} bytes inflates to ` + + `${thousands(size)} bytes, and it inflates to ${thousands(inflated.length)}`, + ); + } + const used = 8 + stream; + return { section: ".data", rel, stream, size, used, padded: Math.ceil(used / ptr) * ptr, data: inflated, at }; + } + throw new Fail( + `${which}: no startup block in .data: no u32 compressed size, u32 inflated size and deflate stream ` + + "that inflates to its stated size", + ); +} + +/** + * Compares two exes outside the COFF time stamp, the optional header's checksum and, + * when `blocks` is given, the startup block. The blocks are lined up on the ends of + * their padding, so that what follows a block is compared with what follows the other + * even when the two are a step apart; the padding is compared on its own, aligned at + * its end. + * + * @returns {{ranges: object[], bySection: Map, notes: string[]}} + */ +function compareOutside(A, B, pa, pb, blocks) { + const masked = (buf, pe) => { + const copy = Buffer.from(buf); + copy.fill(0, pe.stamp, pe.stamp + 4); + copy.fill(0, pe.checksum, pe.checksum + 4); + return copy; + }; + const a = masked(A, pa); + const b = masked(B, pb); + const notes = []; + const segments = []; + const firstRaw = (pe) => Math.min(...pe.sections.filter((s) => s.rawSize).map((s) => s.raw)); + segments.push({ section: "headers", aAt: 0, bAt: 0, aRel: 0, bRel: 0, length: Math.min(firstRaw(pa), firstRaw(pb)) }); + const seg = (sa, sb, aRel, bRel, length, note) => + segments.push({ section: sa.name, aAt: sa.raw + aRel, bAt: sb.raw + bRel, aRel, bRel, length, note }); + for (const sa of pa.sections) { + const sb = pb.sections.find((s) => s.name === sa.name); + if (!sb) { + notes.push(`the section ${sa.name} is only in the first build`); + continue; + } + if (sa.rawSize !== sb.rawSize) { + notes.push(`${sa.name} holds ${thousands(sa.rawSize)} bytes in the first build and ${thousands(sb.rawSize)}`); + } + if (blocks && sa.name === blocks.a.section) { + const [x, y] = [blocks.a, blocks.b]; + const endA = x.rel + x.padded; + const endB = y.rel + y.padded; + const pad = Math.min(x.padded - x.used, y.padded - y.used); + seg(sa, sb, 0, 0, Math.min(x.rel, y.rel)); + seg(sa, sb, endA - pad, endB - pad, pad, "padding after the block"); + seg(sa, sb, endA, endB, Math.min(sa.rawSize - endA, sb.rawSize - endB)); + } else { + seg(sa, sb, 0, 0, Math.min(sa.rawSize, sb.rawSize)); + } + } + for (const sb of pb.sections) { + if (!pa.sections.some((s) => s.name === sb.name)) notes.push(`the section ${sb.name} is only in the second build`); + } + const ranges = []; + const bySection = new Map(); + for (const g of segments) { + if (g.length <= 0) continue; + const one = a.subarray(g.aAt, g.aAt + g.length); + const two = b.subarray(g.bAt, g.bAt + g.length); + for (const [start, end] of diffRanges(one, two)) { + const length = end - start; + ranges.push({ + section: g.section, + note: g.note, + aRel: g.aRel + start, + bRel: g.bRel + start, + length, + first: bytesHex(A, g.aAt + start, length), + second: bytesHex(B, g.bAt + start, length), + }); + const total = bySection.get(g.section) ?? { bytes: 0, ranges: 0 }; + total.bytes += length; + total.ranges++; + bySection.set(g.section, total); + } + } + return { ranges, bySection, notes }; +} + +// ----------------------------------------------------------------- printing + +const MAX_ROWS = 80; + +function rangeLine(r) { + const where = `${r.section}+0x${hexOf(r.aRel)}`.padEnd(16); + const size = `${String(r.length).padStart(3)} byte${r.length === 1 ? " " : "s"}`; + const moved = r.bRel === r.aRel ? "" : ` (second build: +0x${hexOf(r.bRel)})`; + const note = r.note ? ` (${r.note})` : ""; + return `${where} ${size} ${r.first} / ${r.second}${moved}${note}`; +} + +/** Prints what differs outside the stamp, the checksum and any block; returns the byte and range counts. */ +function printOutside({ ranges, bySection, notes }, outside) { + for (const note of notes) console.log(`note: ${note}`); + const bytes = [...bySection.values()].reduce((n, s) => n + s.bytes, 0); + if (!ranges.length) { + console.log(`outside ${outside}: no byte differs`); + return { bytes, count: 0 }; + } + const verb = bytes === 1 ? "differs" : "differ"; + console.log(`outside ${outside}: ${plural(bytes, "byte")} ${verb}, in ${plural(ranges.length, "range")}`); + for (const [section, total] of bySection) { + console.log(` ${section}: ${plural(total.bytes, "byte")} in ${plural(total.ranges, "range")}`); + for (const r of ranges.filter((x) => x.section === section).slice(0, MAX_ROWS)) console.log(` ${rangeLine(r)}`); + if (total.ranges > MAX_ROWS) console.log(` ... ${total.ranges - MAX_ROWS} more`); + } + return { bytes, count: ranges.length }; +} + +/** The bytes [start, end) of `buf` read as a little-endian integer, in hex, padded to `width` digits. */ +function littleEndian(buf, start, end, width) { + const hex = Buffer.from(buf.subarray(start, end)).reverse().toString("hex"); + return `0x${BigInt(`0x${hex}`).toString(16).padStart(width, "0")}`; +} + +/** The two stamps and checksums, which the linker sets and the comparison leaves out. */ +function printHeaderFields(A, B, pa, pb) { + const word = (buf, at) => buf.readUInt32LE(at); + const differ = (at1, at2) => [0, 1, 2, 3].filter((i) => A[at1 + i] !== B[at2 + i]).length; + const s = [word(A, pa.stamp), word(B, pb.stamp)]; + const c = [word(A, pa.checksum), word(B, pb.checksum)]; + const when = (t) => new Date(t * 1000).toISOString().replace(".000Z", "Z"); + console.log( + `PE time stamp: 0x${hexOf(s[0])} (${when(s[0])}) and 0x${hexOf(s[1])} (${when(s[1])}), ` + + `${differ(pa.stamp, pb.stamp)} of 4 bytes differ`, + ); + console.log( + `PE checksum: 0x${hexOf(c[0])} and 0x${hexOf(c[1])}, ${differ(pa.checksum, pb.checksum)} of 4 bytes differ`, + ); +} + +/** + * Compares the two files and prints what differs. Returns the exit code: 1 when the + * decompressed blocks (with `vb6`, the files outside the stamp and the checksum) differ. + */ +function compareBuilds([fileA, fileB], { vb6 }) { + const A = readFileSync(fileA); + const B = readFileSync(fileB); + const pa = parsePe(A, "the first build"); + const pb = parsePe(B, "the second build"); + const ptr = pa.plus ? 8 : 4; + const layout = (pe) => pe.sections.map((s) => `${s.name}:${s.rawSize}`).join(" "); + console.log( + `sizes: ${thousands(A.length)} and ${thousands(B.length)} bytes (${A.length === B.length ? "equal" : "different"}); ` + + `sections ${layout(pa) === layout(pb) ? "the same in both" : `differ: ${layout(pa)} | ${layout(pb)}`}`, + ); + printHeaderFields(A, B, pa, pb); + + if (vb6) { + const outside = printOutside(compareOutside(A, B, pa, pb, null), "the time stamp and the checksum"); + console.log("a VB6 exe has no deflate-compressed startup block"); + console.log( + outside.bytes + ? `the two VB6 builds differ in ${plural(outside.bytes, "byte")} outside the time stamp and the checksum, in ${plural(outside.count, "range")}` + : "the two VB6 builds are equal outside the time stamp and the checksum", + ); + return outside.bytes ? 1 : 0; + } + + const ba = findBlock(A, pa, "the first build"); + const bb = findBlock(B, pb, "the second build"); + const where = (x) => `${x.section}+0x${hexOf(x.rel)}`; + console.log( + `startup block: ${where(ba)} in the first build, ${where(bb)} in the second; stream ${thousands(ba.stream)} and ` + + `${thousands(bb.stream)} bytes, padded to ${thousands(ba.padded)} and ${thousands(bb.padded)}; ` + + `inflates to ${thousands(ba.size)} and ${thousands(bb.size)} bytes`, + ); + const common = Math.min(ba.stream, bb.stream); + const streams = [A.subarray(ba.at + 8, ba.at + 8 + common), B.subarray(bb.at + 8, bb.at + 8 + common)]; + console.log( + `compressed stream: ${thousands(streams[0].reduce((n, v, i) => n + (v === streams[1][i] ? 0 : 1), 0))} of ` + + `${thousands(common)} common bytes differ`, + ); + printOutside(compareOutside(A, B, pa, pb, { a: ba, b: bb }), "the time stamp, the checksum and the block"); + + const sizeGap = Math.abs(ba.size - bb.size); + const ranges = diffRanges(ba.data, bb.data); + const bytes = ranges.reduce((n, [s, e]) => n + (e - s), 0) + sizeGap; + console.log( + `decompressed block: ${thousands(bytes)} of ${thousands(Math.max(ba.size, bb.size))} bytes differ, ` + + `in ${plural(ranges.length, "range")}`, + ); + if (sizeGap) console.log(` the two blocks are ${thousands(sizeGap)} bytes apart in size`); + for (const [start, end] of ranges) { + // The values sit at 4-byte steps in both targets, so a range that fits in one pointer-sized + // value from the step at or before it is printed as that whole value: a byte that is the same + // in both builds, at either end, still belongs to the number. + const from = start - (start % 4); + const whole = end <= from + ptr && from + ptr <= Math.min(ba.size, bb.size); + const [first, last] = whole ? [from, from + ptr] : [start, end]; + const one = littleEndian(ba.data, first, last, whole ? ptr * 2 : 0); + const two = littleEndian(bb.data, first, last, whole ? ptr * 2 : 0); + console.log( + ` +0x${hexOf(first).padStart(3, "0")} ${String(end - start).padStart(2)} bytes ${one} / ${two}` + + ` (${bytesHex(ba.data, start, end - start)} / ${bytesHex(bb.data, start, end - start)})`, + ); + } + console.log( + bytes + ? `the decompressed startup blocks of two builds differ in ${plural(bytes, "byte")}, in ${plural(ranges.length, "range")} (${arch})` + : `the decompressed startup blocks of two builds are equal (${arch})`, + ); + return bytes ? 1 : 0; +} + +// ----------------------------------------------------------------- the builds + +async function buildTwinBasic() { + const ide = findIde(values.ide); + if (!ide || !existsSync(ide)) { + throw new Fail( + (ide ? `no twinBASIC IDE at ${ide}: ` : "no twinBASIC IDE found: ") + + "pass --ide , set TB_IDE, or unpack a twinBASIC_IDE_BETA_ folder on your Desktop", + ); + } + if (!existsSync(path.join(SOURCE, "Settings"))) throw new Fail(`the reproducer's source is missing: ${SOURCE}`); + let lanePort; + try { + [lanePort] = await claimPorts(1, { from: port }); + } catch (e) { + throw new Fail(e.message); + } + work = path.join(tmpdir(), "tbprobe-build-twice", String(lanePort)); + rmSync(work, { recursive: true, force: true }); + mkdirSync(work, { recursive: true }); + tidy = startTidy({ prefixes: [work] }); + const copies = keepFiles ?? path.join(work, "copies"); + mkdirSync(copies, { recursive: true }); + console.log( + `BETA ${buildNumber(ide) ?? "?"}, ${arch}: two builds of bugs/build-writes-compiler-addresses, ` + + `each in an IDE of its own on port ${lanePort}`, + ); + + const files = []; + for (let n = 1; n <= 2; n++) { + const project = path.join(work, "probe.twinproj"); + const out = path.join(work, "out"); + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + stageProject({ + src: SOURCE, + stage: path.join(work, "stage"), + project, + settings: { + "project.buildPath": path.join(out, "${ProjectName}_${Architecture}.${FileExtension}"), + "project.id": laneProjectId(6, lanePort), + }, + }); + const r = await compileProject({ + project, + ide, + port: lanePort, + arch, + timeout, + buildTimeout: timeout, + build: true, + }); + if (r.code !== 0 || !r.built || !existsSync(r.built)) { + const why = [r.message, ...r.rows, ...r.buildLog.slice(-8)].filter(Boolean).join("\n"); + throw new Fail(`build ${n} made no exe (exit ${r.code}):\n${why}`); + } + const copy = path.join(copies, `${arch}-${n}${path.extname(r.built)}`); + copyFileSync(r.built, copy); + files.push(copy); + console.log(`build ${n}: ${thousands(readFileSync(copy).length)} bytes`); + } + return compareBuilds(files, { vb6: false }); +} + +async function buildVb6() { + const exe = findVb6(undefined); + if (!exe) throw new Fail(NO_VB6); + const folder = `${path.relative(REPO, VB6_SOURCE).split(path.sep).join("/")}/`; + if (!existsSync(VB6_SOURCE)) throw new Fail(`the reproducer has no ${folder} folder`); + const why = reproProblem(VB6_SOURCE); + if (why) throw new Fail(`${folder} ${why}`); + work = mkdtempSync(path.join(tmpdir(), "tbprobe-build-twice-vb6-")); + const copies = keepFiles ?? path.join(work, "copies"); + mkdirSync(copies, { recursive: true }); + console.log(`VB6: two builds of ${folder}, both in one folder`); + + // Both builds are made in the same folder, as the twinBASIC ones are: VB6 stores the + // project's folder in the exe, so two folders would be a difference of their own. + const dir = path.join(work, "build"); + const files = []; + for (let n = 1; n <= 2; n++) { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + for (const name of reproFiles(VB6_SOURCE).sources) copyFileSync(path.join(VB6_SOURCE, name), path.join(dir, name)); + // As `bug_repro vb6` builds it: with Unattended Execution, unless the project has a form. + const vbp = path.join(dir, `${REPRO_PROJECT}.vbp`); + const text = decodeAnsi(readFileSync(vbp)); + writeFileSync(vbp, encodeAnsi(unattended(text, !hasVisibleComponent(text)))); + const made = await make(exe, dir, { project: REPRO_PROJECT }); + if (made.timedOut || made.error) { + throw new Fail(`VB6 did not finish building${made.error ? `: ${made.error}` : " in time"}\n${made.log}`); + } + if (!made.built) throw new Fail(`VB6 did not build the project:\n${made.log.trim()}`); + const copy = path.join(copies, `vb6-${n}.exe`); + copyFileSync(path.join(dir, `${REPRO_PROJECT}.exe`), copy); + files.push(copy); + console.log(`build ${n}: ${thousands(readFileSync(copy).length)} bytes`); + } + return compareBuilds(files, { vb6: true }); +} + +// ----------------------------------------------------------------- the run + +let code; +try { + code = values.vb6 ? await buildVb6() : await buildTwinBasic(); +} catch (e) { + if (!(e instanceof Fail)) throw e; + cleanUp(); + die(2, e.message); +} +cleanUp(); +process.exit(code); diff --git a/test/ide/lanes.mjs b/test/ide/lanes.mjs index 3dc29edfd..91eb3dd6a 100644 --- a/test/ide/lanes.mjs +++ b/test/ide/lanes.mjs @@ -28,4 +28,5 @@ export default [ { file: "build-and-exit-silent.test.mjs" }, { file: "recent-projects-copies.test.mjs" }, { file: "typing-after-open.test.mjs" }, + { file: "restart-file-order.test.mjs" }, ]; diff --git a/test/ide/probes/restart-file-order/Settings b/test/ide/probes/restart-file-order/Settings new file mode 100644 index 000000000..6cc2cd31e --- /dev/null +++ b/test/ide/probes/restart-file-order/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "RestartFileOrderProbe", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "test/ide/restart-file-order.test.mjs opens it in a lane's copy of the install, restarts the compiler, switches the build target and saves, and reads the order of the code in each build.", + "project.exportPathIsV2": true, + "project.id": "{7B247500-0000-4000-9000-7B24750000B1}", + "project.name": "RestartFileOrderProbe", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/ide/probes/restart-file-order/Sources/ClsX.twin b/test/ide/probes/restart-file-order/Sources/ClsX.twin new file mode 100644 index 000000000..2d026ed3f --- /dev/null +++ b/test/ide/probes/restart-file-order/Sources/ClsX.twin @@ -0,0 +1,7 @@ +Class ClsX + + Public Function X1() As Long + X1 = &H5A120001 + End Function + +End Class diff --git a/test/ide/probes/restart-file-order/Sources/ClsY.twin b/test/ide/probes/restart-file-order/Sources/ClsY.twin new file mode 100644 index 000000000..9f812fddb --- /dev/null +++ b/test/ide/probes/restart-file-order/Sources/ClsY.twin @@ -0,0 +1,7 @@ +Class ClsY + + Public Function Y1() As Long + Y1 = &H5A130001 + End Function + +End Class diff --git a/test/ide/probes/restart-file-order/Sources/FormF.tbform b/test/ide/probes/restart-file-order/Sources/FormF.tbform new file mode 100644 index 000000000..48caf0a3d --- /dev/null +++ b/test/ide/probes/restart-file-order/Sources/FormF.tbform @@ -0,0 +1,83 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": -2147483633, + "BorderStyle": "vbSizable", + "Caption": "FormF", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA02}", + "HasDC": true, + "Height": 300, + "HelpContextID": 0, + "Icon": [], + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": [], + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "FormF", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": [], + "PaletteMode": "vbPaletteModeHalftone", + "Picture": [], + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 4500, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 4500, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 300, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": false, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 2061908507, + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/test/ide/probes/restart-file-order/Sources/FormF.twin b/test/ide/probes/restart-file-order/Sources/FormF.twin new file mode 100644 index 000000000..fe7fa140b --- /dev/null +++ b/test/ide/probes/restart-file-order/Sources/FormF.twin @@ -0,0 +1,13 @@ +[Description("")] +[FormDesignerId("EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA02")] +[PredeclaredId] +Class FormF + + Sub New() + End Sub + + Public Function F1() As Long + F1 = &H5A0F0001 + End Function + +End Class diff --git a/test/ide/probes/restart-file-order/Sources/Startup.twin b/test/ide/probes/restart-file-order/Sources/Startup.twin new file mode 100644 index 000000000..7e66b5aff --- /dev/null +++ b/test/ide/probes/restart-file-order/Sources/Startup.twin @@ -0,0 +1,13 @@ +Module Startup + + Public Sub Main() + Dim t As Long + t = FormF.F1() + t = t + S1() + End Sub + + Public Function S1() As Long + S1 = &H5A110001 + End Function + +End Module diff --git a/test/ide/restart-file-order.test.mjs b/test/ide/restart-file-order.test.mjs new file mode 100644 index 000000000..ff4ef5b21 --- /dev/null +++ b/test/ide/restart-file-order.test.mjs @@ -0,0 +1,203 @@ +// The order of a project's files, across a compiler restart, Save and a switch of the build target. The +// project in probes/restart-file-order has a Startup module that calls the form FormF and a procedure of +// its own, and two classes, ClsX and ClsY, that nothing references. Every procedure returns a constant of +// its own, so the layout of a build is the order of those constants' four bytes in the exe's .text +// section: X1 Y1 F1 S1 when the project's files come in the order ClsX, ClsY, and Y1 X1 F1 S1 when they +// come in the other. Only the classes that the referenced form keeps move; the form's F1 and Startup's S1 +// stay in the order that Main calls them. +// +// The IDE reverses the order of the project's files each time the compiler restarts and each time the +// build target changes, and Save writes the reversed order to the file. So the same unchanged project +// builds to exes with their code in another order: the first build is laid out as the opened file is, the +// build after a restart in the reverse, and the build after a second restart as the first again. +// +// After a Save, a restart or a switch loads the file Save wrote, which is reversed already, so a switch +// after the Save would show the Save and not the switch. The switch has a scenario of its own, on the +// project opened afresh. +// +// The IDE falls into Safe Mode after four unexpected compiler restarts within a minute: the build box +// (buildConfigSelector) then says nocompile, and a build builds nothing. Whether the restart button +// counts is not known, so the lane leaves SPACING between one restart or switch and the next, and checks +// the box before each build. +// +// Each test states what BETA 997 does. If one fails after an IDE update, the IDE has changed: update the +// entry "The IDE reverses the order of a project's files at each compiler restart, target switch and +// Save, and the code of a build follows that order" in BUGS-TO-REPORT.md, or its report in bugs/filed/, +// and then this file. +// +// Run it with ide-test.bat, which gives it a lane; on its own it is skipped. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { readProject } from "../../scripts/impexp.mjs"; +import { buildProject } from "../../scripts/lib/tb-ide.mjs"; +import { waitFor } from "../../scripts/lib/tb-operate.mjs"; +import { scenario } from "../addin/scenario.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT = path.join(HERE, "probes", "restart-file-order"); + +// The constant each procedure returns: X1 in ClsX, Y1 in ClsY, F1 in FormF and S1 in Startup. +const MARKERS = { X1: 0x5a120001, Y1: 0x5a130001, F1: 0x5a0f0001, S1: 0x5a110001 }; +// The two layouts a build has. The file lists ClsX before ClsY, so a build laid out in the file's +// order has X1 before Y1. +const FILE_ORDER = "X1 Y1 F1 S1"; +const OTHER_ORDER = "Y1 X1 F1 S1"; + +// The least time between one restart or switch and the next. +const SPACING = 21 * 1000; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// The bytes of the .text section of an exe, 32-bit or 64-bit. +function textSection(exe) { + const pe = exe.readUInt32LE(0x3c); + assert.equal(exe.readUInt32LE(pe), 0x4550, "the build is not a PE file"); + const sections = exe.readUInt16LE(pe + 6); + const optionalHeader = exe.readUInt16LE(pe + 20); + for (let i = 0; i < sections; i++) { + const at = pe + 24 + optionalHeader + 40 * i; + if (exe.toString("latin1", at, at + 8).split("\0")[0] === ".text") { + const start = exe.readUInt32LE(at + 20); + return exe.subarray(start, start + exe.readUInt32LE(at + 16)); + } + } + return assert.fail("the build has no .text section"); +} + +// The markers of a build, in the order their code sits in the exe's .text section. Two builds of +// one order also differ in other bytes on win32, so the layout is read from the markers and never +// from a hash of the section. +function layout(file) { + const text = textSection(readFileSync(file)); + const found = Object.entries(MARKERS).map(([name, value]) => { + const bytes = Buffer.alloc(4); + bytes.writeUInt32LE(value); + return { name, at: text.indexOf(bytes) }; + }); + const missing = found.filter((m) => m.at < 0).map((m) => m.name); + assert.deepEqual(missing, [], `${file} holds no code for ${missing.join(", ")}`); + return found + .sort((a, b) => a.at - b.at) + .map((m) => m.name) + .join(" "); +} + +// The paths of a project file's entries in the order the file holds them, a folder before its own +// entries. +function entries(folder, prefix = "") { + return folder.children.flatMap((e) => { + const name = prefix + e.name; + return e.kind === "directory" ? [`${name}/`, ...entries(e, `${name}/`)] : [name]; + }); +} + +// The tree with the entries of every folder in the opposite order. +function reversed(folder) { + return { ...folder, children: folder.children.map((e) => (e.kind === "directory" ? reversed(e) : e)).reverse() }; +} + +// What a scenario does in its IDE: `spaced` waits until SPACING has passed since the last restart or +// switch, and `build` builds the project, which must be in `arch`, and returns the layout of the exe. +function session(c) { + let lastReload = 0; // when the compiler was last restarted, or the build target last switched + return { + async spaced() { + const wait = lastReload + SPACING - Date.now(); + if (wait > 0) await sleep(wait); + lastReload = Date.now(); + }, + // A build box that says nocompile is Safe Mode. + async build(arch) { + const target = await c.evaluate("buildConfigSelector.value"); + assert.equal(target, arch, `the build box says ${target}, not ${arch}`); + const built = await buildProject(c); + assert.ok(built.ok, `the build did not finish: ${built.message}`); + return layout(built.file); + }, + }; +} + +scenario("the order of a project's files across compiler restarts and Save", (lane) => { + let c; + let ide; + let opened; // the project file as the IDE opened it + let first; // the layout of the build before any restart + + before(async () => { + c = await lane.open(PROJECT); + ide = session(c); + opened = readProject(lane.project); + first = await ide.build("win32"); + assert.equal(first, FILE_ORDER, "the first build is not laid out in the order of the project's files"); + }); + + test("after a compiler restart, a build lays the code out in the reverse order of the build before it", async () => { + await ide.spaced(); + await lane.restartCompiler(); + const second = await ide.build("win32"); + assert.equal( + second, + OTHER_ORDER, + `the build before the restart is laid out ${first}, the build after it ${second}`, + ); + }); + + test("after a second restart, a build lays the code out in the order of the first build again", async () => { + await ide.spaced(); + await lane.restartCompiler(); + const third = await ide.build("win32"); + assert.equal(third, first, `the first build is laid out ${first}, the build after two restarts ${third}`); + }); + + test("Save writes the project's files in the reverse of the order they were opened in", async () => { + const was = readFileSync(lane.project); + await c.evaluate(`executeIdeCommand("tbProject_SaveAllChanges")`); + let saved = null; + const written = await waitFor( + c, + async () => { + try { + if (readFileSync(lane.project).equals(was)) return false; + saved = readProject(lane.project); + return true; + } catch { + return false; // the IDE is still writing the file + } + }, + { timeout: 30 * 1000 }, + ); + assert.ok(written, "Save did not change the project file"); + assert.deepEqual( + entries(saved), + entries(reversed(opened)), + "Save did not write the project's files in the reverse of the order they were opened in", + ); + }); +}); + +scenario("the order of a project's files across a switch of the build target", (lane) => { + let c; + let ide; + let first; // the layout of the build for win32, before the switch + + before(async () => { + c = await lane.open(PROJECT); + ide = session(c); + first = await ide.build("win32"); + assert.equal(first, FILE_ORDER, "the first build is not laid out in the order of the project's files"); + }); + + test("after a switch to win64, a build lays the code out in the reverse order of the build before it", async () => { + await ide.spaced(); + await lane.setBuildTarget("win64"); + const switched = await ide.build("win64"); + assert.equal( + switched, + OTHER_ORDER, + `the build for win32 is laid out ${first}, the build after the switch to win64 ${switched}`, + ); + }); +}); From e8dae408863114d2bd2237cf77a0c8c3657522a3 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 16:24:50 +0200 Subject: [PATCH 14/33] WIP.Search.md: the round-by-round journal becomes the current design by topic; citations follow --- WIP.Search.md | 1410 ++++++++--------- builder/render.mjs | 4 +- builder/search.mjs | 9 +- builder/vendor/just-the-docs/README.md | 27 +- .../just-the-docs/assets/js/just-the-docs.js | 21 +- eval/search-experiments/README.md | 6 +- eval/search_quality.mjs | 8 +- test/search.test.mjs | 23 +- 8 files changed, 694 insertions(+), 814 deletions(-) diff --git a/WIP.Search.md b/WIP.Search.md index 2512731d0..d4f1e9c4a 100644 --- a/WIP.Search.md +++ b/WIP.Search.md @@ -32,20 +32,20 @@ truths: adding the name-and-kind set lowered the overall hit@1 from 99.7%. **Known misses (185 at rank 1)**, each recorded where it was diagnosed: - 158 name-and-kind queries, mostly properties; not diagnosed yet - ([Fixed: kind words](#fixed-kind-words)). + ([Kind words](#kind-words)). - 10 bare names: 7 enum constants, 2 members, and `MidB$` - ([Same-page sections count](#same-page-sections-count)). + ([Ground truth sets](#ground-truth-sets)). - 3 page titles and 10 page-plus-section queries, each at rank 2 or 3 - ([Fixed: whole titles](#fixed-whole-titles)). + ([Known misses and limits](#known-misses-and-limits)). - 1 page title, `Input #`, at 2 behind the `Input` function: the query trims to `input`, and the title set skips one-word titles. - 3 prose queries, `declaration`, `comment` and `default property`, whose Glossary definitions are 2nd, 5th and 5th. That is right by the user's ruling (a Glossary definition counts within the top 5). An entry would reorder the bare names `Declare` and `Comments` - ([Item 6: shipped](#item-6-shipped)), and one for `default property` would - put it above CommandButton's `Default` - ([Item 7](#item-7-content-and-index-entries-from-two-surveys)). + ([Index entry decisions](#index-entry-decisions)), and one for `default + property` would put it above CommandButton's `Default` + ([Index entry decisions](#index-entry-decisions)). **Next**, for the user to choose from: 1. **The 158 name-and-kind misses.** Diagnose them as the whole-title probes @@ -58,12 +58,12 @@ truths: adding the name-and-kind set lowered the overall hit@1 from 99.7%. 3. **The cost of one-letter words.** `a p` takes about 250 ms and a single letter 80–110 ms, lunr's own work. Cheaper means changing the query, and so the ranking, for instance not completing a one-letter word - ([Fixed: slow multi-word queries](#fixed-slow-multi-word-queries)). + ([lunr patches](#lunr-patches)). 4. **More content and index entries**, under the rules below. Open: `COM interop` (no clear target) and three content gaps: subclassing, IntelliSense and conditional breakpoints (first find out whether the IDE has the last two). See - [Item 7](#item-7-content-and-index-entries-from-two-surveys). + [Known misses and limits](#known-misses-and-limits). 5. **An extra word hides a page.** `memory window`, `history panel`, `registry access`: the all-words pass finds a few pages holding both words and doesn't fall back, so the page named by the other word never @@ -78,7 +78,7 @@ truths: adding the name-and-kind set lowered the overall hit@1 from 99.7%. `names`, `qualified` and `primary` are already a hand index, generated from `tB/symbols.json`. Jargon is the hard case, because no ranking can find a page for words it doesn't contain. So authors mark index entries by hand -([third round](#what-shipped-third-round-the-index-pilot)). +([Hand-marked index entries](#hand-marked-index-entries)). **Rules for index entries:** - An entry names the page a reader wants for that term, not a summary of the @@ -229,18 +229,18 @@ page for words it doesn't contain. So authors mark index entries by hand minimisation keys nodes by `TokenSet#toString()`, which runs edge labels into child ids (`{1 -> 656}` and `{1 -> 6, 5 -> 6}` are both `01656`). Whether it happens depends on the whole term set, so any content or field - change can start it. Patched; see "Fixed: lunr invented words". Check a + change can start it. Patched; see "lunr patches". Check a candidate change with every title as a query, not only the eval, since the eval never types `a`. - The search data must keep the page's HTML entities: the results panel inserts titles and content with `innerHTML` and highlights by lunr's character positions in that text. Decode inside the index instead, per token, after the tokenizer splits (`Token#update` keeps the position); see - "Fixed: entities in the index". + "lunr patches". - lunr 2.3.9's `Set#union` copies both sets, and `Index#query` unions once per expanded term and field of a REQUIRED clause, so a short wildcard word - was quadratic. Patched (`accumulateSetUnions()`); see "Fixed: slow - multi-word queries". Profile before guessing: `node --cpu-prof`. + was quadratic. Patched (`accumulateSetUnions()`); see "lunr patches". + Profile before guessing: `node --cpu-prof`. ## The problem @@ -382,8 +382,7 @@ boosted on its own, rank better than one field at any single boost: and deduplicated. Client boost 100. - `qualified`: the `Container.Name` form of the same symbols (only those that have a container -- a bare statement or operator does not), space-separated - and deduplicated. Client boost 500 (see "What shipped, fourth round: - qualified names"). + and deduplicated. Client boost 500 (see "Qualified names and stem twins"). With the current corpus, 4,084 of 6,713 entries get either field. @@ -565,14 +564,15 @@ its numbers from the harness. ### The criterion A reader either finds what they want at the top or doesn't, so "small -regressions" are not acceptable, and neither is the pre-rollout baseline as a +regressions" are not acceptable, and neither is an earlier index's rank as a reference. A result is judged by what a reader typing the query most plausibly wants. Rank 1 is the measure that matters; hit@10 and MRR are secondary. -The first ground truth counted a bare name as correct if *any* page -documenting that name was hit. That is too lax: for `CheckBox` it accepts -`DTPicker › CheckBox`, but the reader wants the CheckBox control. +A bare name counts as correct only for the best tier that documents it, not +for any page documenting that name (the eval still reports that looser rule +as `bare, any page`): for `CheckBox` it accepts `DTPicker › CheckBox`, but the +reader wants the CheckBox control. ### The intent ground truth @@ -581,9 +581,12 @@ first, and lower tiers must still appear below them; nothing is excluded: 1. **Type names and language elements.** A type is a class, control, module, enum, interface or package. A language element is a statement, keyword, - operator, attribute or directive with no package, or a function or - property whose container is a module (so `Time$` → DateTime's `Time`, and - `Left` → the Strings function, not the `Left` property of 30 controls). + operator, attribute or directive with no package, or a function, property, + sub or method whose container is a module (so `Time$` → DateTime's `Time`, + and `Left` → the Strings function, not the `Left` property of 30 + controls). `isPrimarySymbol` in `builder/search.mjs` states this rule for + the index; `intentTier` in `eval/search_quality.mjs` states it again, + independently, as the ground truth the index is measured against. 2. **Members** of classes. 3. **Enum constants and similar** (`vbForm` → `ControlTypeConstants`). 4. **Prose.** @@ -603,141 +606,457 @@ script: mostly statements with several pages (`For` has For...Next and For Each...Next; `GoSub`, `On`, `Input`), plus `Line` and `Timer`, where a type outranks a same-named statement or enum and either could be argued. -It is `eval/search_quality.mjs`'s primary ground truth; the experiments' copy -is +The tiers are `eval/search_quality.mjs`'s primary ground truth; the +experiments' copy is [eval/search-experiments/intent/intent_gt.mjs](eval/search-experiments/intent/intent_gt.mjs). -**Prose ground truth.** The expectations for `64-bit compilation`, `Fusion`, -`symbol index` and `array bounds checks` are right, so their misses are -ranking problems. For `conditional compilation` the user chose -`/tB/Core/Topic-Preprocessor` (the `#If`/`#Const` page), with -`/Reference/Compiler-Constants` right behind. - -### Three fixes, measured - -Each was a client-side change to a copy of the replica, -[eval/search-experiments/intent/variants.mjs](eval/search-experiments/intent/variants.mjs), -measured by `eval_variants.mjs` beside it. - -- **X1, an exact-name field.** It fixes an exact name losing to a longer or - plural one (`Node` to `Nodes`, `ListItem` to `ListItems`, `vbForm` to - `vbFormCode`), and `Time$`, whose `$` the index trims but the query kept. - At index-build time, every name in the doc's `names` field is lowercased - and suffixed with `_` into a field `exact`: `node_`, `time$_`, `vb_`. At - query time, for each whitespace token, the term `token + "_"` is added on - `exact` only, with no wildcard. The trimmer keeps `_` as a word character, - and every Porter stemmer rule anchors on the end of the word, so a trailing - `_` passes through untouched. It is derived in the browser, so the download - doesn't grow. -- **X2, a page-title field.** Index each entry's `doc` (its page's title) as a - field `page`. A page whose own title matches the query then outranks a - summary section on another page (`Features › Fusion` against the Fusion - page). -- **X3, all words first.** With 2+ whitespace tokens, first query with every - token REQUIRED (each via its wildcard form, so an exact or a prefix match - satisfies it; the exact and dot-split clauses stay optional). If that finds - nothing, fall back to the plain query. It doesn't move the aggregate, but - it is the only fix for `symbol index` (rank 39 → 3): the `Index` property - pages never contain "symbol". - -| configuration | hit@1, old ground truth | hit@1, intent | -|---|---|---| -| before (committed) | 89.5% | 89.0% | -| X1 | 90.1% | 89.5% | -| X2 | 92.3% | 91.8% | -| X3 | 89.5% | 89.0% | -| X1t (tiered exact fields, below) | 90.0% | 89.3% | -| X1 + X2 | 92.9% | 92.4% | -| **X1 + X2 + X3 (recommended)** | **92.9%** | **92.4%** | - -The recommended combination made no query worse; cost was +13% index build -and +11% heap, paid only when a reader starts searching. - -### Rejected: tier-specific exact fields (X1t) +### Ground truth sets + +`eval/search_quality.mjs` records its ground truth in the baseline as a label +(`intent-1` to `intent-5`), so `--compare` can tell a changed ground truth from +a changed ranking. Each set below was added to the label in turn. + +- **Bare names** (`intent-1`), judged by the tiers above, and **qualified + names** (only that symbol's URL counts), both derived from + `tB/symbols.json`. +- **Same-page sections count** (`intent-3`). Where a symbol's URL is a page, + not a section of one, any section of that page counts for it too. `DefInt` + is documented by the Deftype page, and its section headed `DefBool, DefByte, + DefInt, ...` lands the reader on the same definition; so do + `Chr#chr-chrb-chrw` for `ChrB` and `Left#left-leftb` for `LeftB`. It applies + to bare, qualified and name-and-kind queries, not to prose, which already + matches by path. `MidB$` stays a miss, rightly: its first result is the + `MidB =` statement (`/tB/Core/MidB-equals`), a different page, and the Mid + function it names is second. +- **Page titles** (`intent-4`): every page's own title of two or more words, + as the reader sees it (entities decoded: the data holds `<<`); any + entry of that page counts. Titles with no letter or digit (the operator + pages, `&, &=`, `<<, <<=`) are left out, since a reader types one operator, + which the bare-name set measures. +- **Page plus section** (`intent-4`): `
` for the + one-word section titles 20 or more pages share (`DTPicker Properties`), + less sections that document a symbol. Only that section counts; a query + several sections share accepts any of them. +- **Name and kind** (`intent-5`): ` ` for every symbol whose kind + is a kind word (`MaxHeight property`, `Continue statement`), less `sub` and + `member`, which readers don't say (a Sub is a method to them; spelling a Sub + as `method` would need another query pass, which lunr's cost rules out for + now). Operators, with no word character, stay out, as the bare-name set + measures them. Any symbol of that name and kind counts, and any section of + a page that documents one. The set was agreed with the user as ground truth + and has three caveats: the kind-word fallback was designed on it, so it + starts near its best (a regression guard, flattering for gains); several + symbols of one name and kind (`Name property`) are judged leniently, any of + them counting; and at 1,785 queries it moves the overall hit@1, so compare + categories, not totals, across ground truths. `KIND_WORDS` is exported from + the replica, so the eval and `probes/kinds.mjs` build the set from the list + the replica searches with. +- **Prose** (`intent-2` for its two expectation changes): the hand-picked + queries in `eval/search_prose_queries.json` (108), each with a + maintainer-judged expected page, matched by path only. The expectations for + `64-bit compilation`, `Fusion`, `symbol index` and `array bounds checks` are + right, so a miss on them is a ranking problem. A query may name + `behind` pages that should be found right behind the expected one, within + the top 3 (`BEHIND_WITHIN`). For `conditional compilation` the user chose + `/tB/Core/Topic-Preprocessor` (the `#If`/`#Const` page), with + `/Reference/Compiler-Constants` right behind. For `symbol index` the user + ruled that Permanent-Links' definition counts as well as Building's + section, and the definition ranks first on its own text, so it needs no + index entry. + +The eval's symbol queries are one word each, so its page-title and +page-plus-section sets are its only multi-word queries. The two probe scripts +under `eval/search-experiments/probes/` (`spaced.mjs`, `kinds.mjs`) cover the +rest; see Tools above. + +### Where each piece lives -Splitting `exact` into `exact1`/`exact2`/`exact3` by symbol kind, with -descending boosts, measured no better than flat X1 (hit@1 90.0% against -90.1%) and ordered tiers worse (48 violating queries against 42). It would -also need the build's join to emit each symbol's kind. **This is superseded:** -it was measured while a lone clause's boost cancelled out, so it never had a -fair test. Done with field boosts, as the `primary` field, it took tier-order -violations to zero (see "What shipped, second round"). Don't rebuild X1t -without a new idea. +The online client is the vendored +`builder/vendor/just-the-docs/assets/js/just-the-docs.js`. +`builder/offline.mjs` replaces `initSearch()` with its own copy +(`JTD_INITSEARCH_FN_REPLACEMENT`), which builds the same fields at the same +boosts. Everything else the search uses sits outside `initSearch()` +(`doSearch()`, `exactName()`, `indexField()`, `qualifiedField()`, +`stemTwins()`, `boostWholeTitles()`, `namesTheThing()` and the lunr patches), +so the offline page shares it. `eval/site_search.mjs` is the replica, which +mirrors all of it. A change to `doSearch()` or a helper is therefore two +copies (client, replica); a change to the field list, the tokenizer wrapper +or the patch installs is three (online `initSearch()`, offline +`initSearch()`, replica). + +`test/search.test.mjs` has one guard per topic below (reader intent, +qualified names and stem twins, index marks and the index terms, token-set +keys, whole titles, entities, set union, kind words, the sliced build). Each +compares the online client, the offline copy where it has its own, and the +replica, by pattern or by behaviour, so a change to one copy fails until the +others agree. A guard's mutations (a boost changed, a clause dropped) are +meant to fail it. What each asserts: + +- **Reader intent:** all three derive `exact` with `exactName()` and `page` + from `doc`; the client and the replica write names and kind words alike, + build the same query and compare kind words as typed. +- **Qualified names:** the client keeps plain words off `qualified` as the + replica does, and the replica ranks the member a qualified name names first. + These mutations each fail it: boost 50, plain words completing in + `qualified`, no word pairs, the REQUIRED clause scoring in `qualified` or + dropped off it. +- **Stem twins:** all three fill `qualified` through `qualifiedField()` and + both queries match a whole name there; the client's `stemTwins()` and + `qualifiedField()` equal the replica's by behaviour; the replica ranks twins + apart in a small index, typed with a dot or as two words. +- **Index marks:** the path from page to entry (front matter to the page's + entry, a heading's mark by id, terms trimmed and kept once regardless of + case, a main term not also secondary; a value that isn't a term or a list, + or a heading no entry holds, fails the build), `render.mjs` lifting the + attributes off headings (a pinned id is the key; any other element fails), + one main entry per term (case and hyphens ignored; a secondary entry + elsewhere is fine), all three filling `index` and the content and pinning + the average length, a term written alike by the client and the replica, and + the replica ranking a marked entry first and its secondary entry next. +- **Token-set keys:** lunr's own keys still invent and lose words for the + fixture; the client's and the replica's keep it exact; all three install + them when they build the index. +- **Whole titles:** both copies boost the same results by the same factor and + apply it to the final results; the replica ranks a fixture like the site + (`DTPicker Properties` behind the class heading without the boost). +- **Entities:** all three wrappers call the decoder; the client and the + replica decode alike and keep each token's position; the replica finds + `&H80004005` and highlights it where it is written. +- **Set union:** both copies' `union` against lunr's own on a chain of sets + (same elements and length at every step, inputs untouched, the running total + added to in place); the replica ranks exactly as with lunr's own; all three + install it. +- **Kind words:** both copies' `namesTheThing()` agree on the same results and + both fall back only when nothing found names the thing; the replica finds a + member its section doesn't call a property (`MaxHeight`) and keeps a titled + match (`Mid`). +- **Sliced build:** the index equals `lunr()`'s, sliced finely and as the site + slices it; a build that throws reports it and puts `lunr.idf` back. + +### Index fields + +| field | holds | boost | +|---|---|---| +| `title` | the section's heading | 200 | +| `content` | the section's text, plus the entry's index terms as plain words | 2 | +| `names` | bare names of the symbols documented at the entry's URL (build join) | 100 | +| `qualified` | their `Container.Name` forms (build join), plus the stem twins whole | 500 | +| `exact` | each `names` entry as `exactName()` writes it; derived in the browser, so the download doesn't grow | 50 | +| `primary` | the `names` that are types or language elements (build join, `isPrimarySymbol`), as `exactName()` writes them | 1000 | +| `page` | the page's own title (`doc`), so a page whose title the reader typed outranks a section of another page that only mentions it | 5 | +| `index` | hand-marked terms (see below) | 1000 | +| `relUrl` | the entry's URL | 1 (lunr's default) | + +**`exactName()`** lowercases a name, spells every non-word character as `_` +and its hex code, and appends `_` (`#if` → `_23if_`, `time$` → `time_24_`, +`<>` → `_3c_3e_`). The index trims non-word characters from token ends, which +would turn `#If` into `if` and `<>` into nothing; spelled out, they survive, +so `#If`, `Time$` and `Error$` stay distinct from `If`, `Time` and the `Error` +statement, and the 24 operators are findable. The final `_` keeps a whole-name +query off every longer name that starts with it (`Node` against `Nodes`, +`ListItem` against `ListItems`, `vbForm` against `vbFormCode`): the trimmer +keeps `_` as a word character, and every Porter stemmer rule anchors on the +end of the word, so a trailing `_` passes through untouched. + +**Why `primary` exists.** Same-name entries of different tiers tie on +`exact`, and BM25's length normalisation then favours the short member +entries: `Left`, `Right`, `BorderStyle`, `WindowState`, `Next`, `Default`, +`Month`, `Print`, `Lock`. An enum page loses further, since its `exact` also +holds every constant's name. `primary` lists only the names that are types or +language elements and weighs them at 1000, so a type or language element +comes before a member of the same name. + +**Boosts come from the fields.** A clause alone on a field has its boost +divided back out (see "How lunr behaves here"), so the exact-name clause +carries none and the field boosts decide. Boosts of 50 to 1000 on `exact` +measured the same. + +**Measured, and rejected:** +- `primary` at boost 200 left 9 names out of tier order, at 500 left 2; 1000 + leaves none. +- `page` at boost 10 or 20 puts the `Folder` page above `Folder.Parent` and + `Folder.Path`, which are documented on `FileSystemItem`; 10, 20 and 50 each + also move `Pointer` from 6 to 7 (50 also `MidB$` from 2 to 3). Boost 5 + keeps both. + +### Query construction + +`doSearch()` builds the query in these steps. The first three apply to every +query. + +1. **Tokens.** `lunr.tokenizer` (the wrapper, see "lunr patches"), then each + token trimmed with `lunr.trimmer`, and tokens left empty dropped. Trimming + as the index trims keeps every `Xxx$` qualified query that the `page` + field would otherwise make worse, finds each bare `Xxx$` function, and + subsumes the asterisk guard, since an all-`*` token trims to nothing. + These are `baseTokens`. +2. **Smart dot split** (Design §3). A token with a split dot (`qualifiedTokens`) + is kept whole and also adds its parts of two or more characters. A part + from a split is a plain token. A split dot is found with a NUL marker, + not a lookbehind. +3. **What the query names.** A query names one thing if it has one + whitespace word, or one word besides words that name a kind + (`KIND_WORDS`: `operator`, `statement`, `attribute`, `keyword`, + `directive`, `class`, `method`, `property`, `module`, `function`, + `constant`, `enum`, `object`, `member`, `sub`, `package`, `interface`, + `control`, `event`, `type`, `field`; the kinds in `tB/symbols.json` less + `enumvalue`, which nobody types). `With statement` names `With`; `error + handling` names nothing, since `error` alone isn't what the reader named. + Only the singular counts, compared as typed: `Delegate Types` and `New + Functions` name a topic, not the one thing `Delegate` or `New`. + +The any-words query (`anyWords()`) holds these clauses. The text fields are +`title`, `content`, `names`, `qualified`, `page` and `relUrl`; the plain-word +fields are those less `qualified`. No clause names `exact`, `primary` or +`index` except its own, since a wildcard such as `node*` would match `nodes_` +there. + +| clause | fields | notes | +|---|---|---| +| every token, whole tokens and split parts | text fields | clause boost 10, no wildcard | +| each plain token | plain-word fields | trailing wildcard | +| each token with a split dot | text fields | trailing wildcard | +| each two adjacent plain tokens, joined with a dot | `qualified` | clause boost 10 | +| each raw word that holds a dot, and each two adjacent raw words joined with a dot, as `exactName()` writes them | `qualified` | clause boost 10; matches only the names held whole (the stem twins) | +| the name the query names, as `exactName()` writes it | `exact`, `primary` | no wildcard, no boost (field boosts decide); only when the query names one thing | +| each run of up to four consecutive tokens, as `phraseKey()` writes it | `index` | boost 5, and the key plus `_` at boost 1; no pipeline | + +Only a token with a split dot may complete with the trailing wildcard in +`qualified`: a plain word there would complete to every member of each +container whose name it begins (`vbfile*` to all of `vbfileattribute.*`), and +at a high field boost lifts that container by its member count. + +**The passes**, in order, stopping at the first that finds anything: + +1. **All words first**, when there are two or more tokens. Each token is + REQUIRED as its stem plus a trailing wildcard, with `usePipeline: false` + (the index holds stems, and an unstemmed `operator*` misses `oper`), + over the text fields at boost 0, so the clause decides only which entries + qualify. A second, optional clause scores the same stem on the plain-word + fields, or the text fields for a token with a split dot. The any-words + clauses are in this query too, so they score. Taking `qualified` out of + the REQUIRED clause altogether loses entries that name their container + nowhere else (`_HiddenModule Input`, `CefEnvironmentOptions LogFilePath`), + hence the split between presence and score; lunr ORs a REQUIRED clause's + fields and adds boost-0 terms to the query vector at zero weight, so the + split changes no presence and no other score. The REQUIRED clause is also + restricted to the text fields because it would otherwise score in `exact`, + which put the wrong page first for `With statement` and pushed others down + for `error handling`; `With statement` gets its rank 1 properly, from the + exact-name clause for the one thing it names. +2. **Kind words optional**, when the query names one thing in two or more + words and no entry the first pass found matched the name (see "Kind + words"). The result replaces the first pass's only if it is not empty. +3. **Any words**, when the query still has no result and has tokens, or names + something (a name with no word character, such as `<>`, leaves no tokens + but its exact-name clause can still match). +4. **Fuzzy**, when still empty, the input is longer than 2 characters and has + tokens: the tokens under 20 characters, at an edit distance of at most 2 + (`min(2, round(sqrt(length / 2 - 1)))`). Upstream applied the distance from + the whole query's length to every word, and lunr's fuzzy expansion grows + exponentially with it: three unindexed API names (49 characters, distance + 5) froze the page for 5 s at 1.6 GB, and four (70, distance 6) for over a + minute. + +The results then go through `boostWholeTitles()` ("Whole titles"). + +**Measured, and rejected:** +- Requiring each raw whitespace word, neither lowercased nor split like the + index, makes every capitalised or hyphenated phrase silently fall back to + the plain query. Lowercased properly, `AddressOf operator` and `64-bit + compilation` vanish, because `operator*` can't match the stem `oper`. So + each lunr token is required as its stem plus a trailing wildcard. +- The exact-name clause on every query lets `Error` take `error handling`'s + rank 1, so it runs only for a query that names one thing. +- The all-words pass is the only fix for `symbol index` (rank 39 to 3): the + `Index` property pages never contain "symbol". + +### Kind words + +A query naming one thing and its kind (`MaxHeight property`, `Continue +statement`, `VbTriState enum`) fails when the all-words pass +requires the kind word and a member's section rarely says "property", +"method" or "event" (`Continue`'s page never says "statement"). + +`namesTheThing()` tells whether a result matched the name: in `exact` or +`primary`, or every word of it in `title`. For a query naming one thing in +two or more words, if no entry the all-words pass found matched the name, the +pass runs again with the kind words not required (they still score). -**The CheckBox control's `names` field does not list every member**, as the -X1t measurement blamed it for doing. Both entries' `names` are the -single word `CheckBox`. The real difference: the CheckBox page's top entry -(`/tB/Packages/VB/CheckBox/`) has **1 character** of content, while -`DTPicker › CheckBox` has 467 characters that mention "checkbox" several -times. A class's introduction sits under its own heading (`#checkbox-class`), -which leaves the page's top entry empty. This is the lead for `CheckBox` and -probably for the other types that miss (`Line`, `Timer`, `BorderStyle`): look -at how a page's top entry and its first section are split, and consider -merging an empty top entry into the first section, or giving it the page's -introduction. - -### What shipped - -X1 + X2 + X3 as measured made 14 queries worse, so four corrections, each -measured, are part of the shipped design: - -1. **X3's required terms.** Requiring each raw whitespace word, neither - lowercased nor split like the index, makes every capitalised or - hyphenated phrase silently fall back to the plain query. Lowercased - properly, `AddressOf operator` and `64-bit compilation` vanish, because - `operator*` can't match the stem `oper`. So each lunr token is - required as its stem plus a trailing wildcard, with `usePipeline: false`. -2. **X1's boost.** A clause alone on a field has its boost divided back out - (see "How lunr behaves here"), which is why boosts 50–1000 measured the - same. Field boost 50 on `exact`; the other clauses name every - field but `exact` (otherwise `node*` matches `nodes_`); `exact` is queried - only for a one-word query (`error handling` loses its answer to - `Error` otherwise). How `exactName()` spells `$` and other non-word - characters is under the second round. -3. **X2's boost is 5, not 20.** At 10 or 20 the `Folder` page outranks - `Folder.Parent` and `Folder.Path`, which are documented on `FileSystemItem`. -4. **Query tokens are trimmed as the index trims them** (`lunr.trimmer`). This - keeps 8 `Xxx$` qualified queries that X2 would make worse, finds every bare - `Xxx$` function, and subsumes the asterisk guard, - since an all-`*` token trims to nothing. - -Both client copies are checked in a browser against -`eval/site_search.mjs`. `test/search.test.mjs`'s reader-intent guard covers -the fields and the query. - -### What shipped, second round: tiers in the index - -The remaining failures sorted into four mechanisms, measured one at a time: - -1. **Same-name entries of different tiers tied, and length decided.** `Left`, - `Right`, `BorderStyle`, `WindowState`, `StartupPosition`, `Next`, - `Default`, `Month`, `Print`, `Lock`: the function, enum or statement and - the members all matched `exact` equally, and BM25 then favoured the short - member entries. An enum page lost further, since its `exact` also held - every constant's name. Fixed with a `primary` field: the build's join - lists the names that are types or language elements (`isPrimarySymbol` in - `builder/search.mjs`), and the client indexes them as exact names at field - boost 1000. Boost 200 left 9 names out of tier order; 500 left 2; 1000 - leaves none. -2. **`#If`, `#Const`, `#Else` lose their `#`** to lunr's trimmer and match - the `If` function. `exactName()` spells every non-word character as - `_` and its hex code (`#if` → `_23if_`), so they stay distinct. So do - `Time$` against `Time` and `Error$` against the `Error` statement, and - the 24 operators (`<>`, `&=`, `*`) become findable: the exact-name clause - runs even when a query leaves no tokens. -3. **REQUIRED clauses searched every field** (see "How lunr behaves here"). - They are restricted to the text fields. -4. **`With statement` then fell to 2**, because its rank 1 had come from that - leak. Fixed properly: a query names one thing if it has one word, or one - word besides words naming a kind (`KIND_WORDS`, the kinds in - `tB/symbols.json` less `enumvalue`). `error handling` still names nothing. - -Result: bare hit@1 98.8%, language elements 94.7%, types 100% in the top 10, -names out of tier order 0; 53 queries better, none worse; index build +5–6%. -`test/search.test.mjs` compares `exactName()` and `KIND_WORDS` by behaviour -across the online client and the replica. - -### What shipped, third round: the index pilot +Where requiring the word worked, it kept a different kind of the same name +out: `Mid function` finds a section of the Mid page, titled `Mid`; with the +word optional, the `Mid =` statement page, which never says "function", comes +in on its exact name. The exact-name test alone fell back there, since a +section carries no names, so the title counts as well. + +**Measured, and rejected** (name-and-kind hit@1 over every symbol written as +its name and kind): +- Kind words never required: 93.9%, but 22 queries worse (`Mid function`, + `Line statement`, `Timer event`), `Loop Control` 1 to 2 in the eval, and 3 + worse among qualified names typed as two words (`File Type`). +- Optional only when no result has the exact name: 94.7%, 4 worse (`Mid + function`, `Stop method`). +- Optional only when no result has the name in its title or as its exact + name: 91.1%, none worse. This is the shipped rule. + +A plural kind word does not make a name (`Delegate Types`), see "Query +construction". + +### Whole titles + +After lunr ranks, a query of two or more tokens multiplies by 3 +(`WHOLE_TITLE_BOOST`) the score of every result whose title, or page title +plus title, reads the same as the query, and re-sorts. Both sides are written +as `indexTermKey()` writes a term (tokenized, trimmed, stemmed), so case, +punctuation and word endings don't matter. Without it a one-word entry beats +a page titled with both words: `Return Syntax` loses to `Return`, `DTPicker +Properties` to the DTPicker class heading (the class heading has `DTPicker` +in its title at 200, the section only in `page` at 5). It adds no index term +and no field. Each entry's two keys are computed once, on its first +appearance in a result, about 100 ms on the first multi-word query after the +index is built and nothing measurable after. + +`boostWholeTitles()` is at the end of `doSearch()`, which is outside +`initSearch()`, so the offline copy shares it. The replica needs `docs` +(`load()` returns it, and the test fixtures pass it too), so that it cannot +quietly skip the re-rank the client applies. + +**Measured, and rejected** (page-title hit@1, page-plus-section hit@1): +- Whole title x1.5: 90.6%, 55.7%. x2: 91.9%, 85.0%. x3: 91.9%, 95.0%. +- x5 and x10: 94.0%, 96.7%, but 21 and 23 of the qualified names typed as two + words get worse (99.59% and 99.55% against 100%). At x5 `_App Comments` + lifts the `App` page's Comments section, since the trimmer drops the + leading `_`. x3 leaves a margin. +- With singular-only kind words (see "Query construction") and x3 together, + page-title hit@1 is 94.0% and page-plus-section 96.0%, with nothing worse. + +### Qualified names and stem twins + +`qualified` is boosted 500. Every entry holds the right token +(`FileListBox.Name` whole, and the query keeps it whole beside its parts), so +this is weighting, not recall. At boost 50 the container's page came first +(`FileListBox.Name` gave the FileListBox page, whose title matches +`filelistbox` at 200, above `#name`), and a heading naming several members +(`KeyDown, KeyPress, KeyUp`) came 20th behind every other control's single +`KeyDown`, since BM25 discounts its three-name title and `names`. + +The qualified-name rules, all in the query table above: +- Only a token with a split dot completes in `qualified`. +- Two adjacent plain words are also a term on `qualified`, joined with a + dot, at clause boost 10 (`FileListBox Name`). Without them two words + naming a member had nothing tying them together. The pairs are exact terms: + pairs that also completed with the wildcard moved `File I/O` from 1 to 2. +- The REQUIRED clause is split into presence and score, as under "Query + construction". + +**Measured, and rejected:** + +| `qualified` boost | plain words complete in `qualified` | hit@1 | worse | +|---|---|---|---| +| 50 | yes | 94.18% | | +| 200 | yes | 98.83% | 1 (`vbFile` 1 to 2) | +| 1000 | yes | 99.41% | 4 (`error handling` 1 to 3, `System`, `TextAlign`, `vbFile`) | +| 300 | no | 98.93% | 0 | +| 500, 700, 1000 | no | 99.46% | 0 | + +Every regression came from one mechanism: a plain word completing in +`qualified` by the trailing wildcard (`vbfile*` to all of +`vbfileattribute.*`), which at boost 500 pulls a container up by its member +count. For two-word queries, the boost alone lost a good section from the top +three on five probes (`form*` completing to every `form.*` in the REQUIRED +clauses). Taking `qualified` out of the REQUIRED clauses (96.93% spaced, 45 +worse) lost entries that name their container nowhere else; the shipped +split, plus the word pairs, gives 99.82% spaced with none worse. + +**Stem twins.** `Printer.Font` and `Printer.Fonts` both stem to +`printer.font`, so they tied in `qualified` and everywhere else a query for +either reached (`Printer.Fonts` came second; so did `Collection.Item`, +`Global.Printers`, `OLE.Update`, `Report.Page` and both +`WebView2*Headers.GetHeaders`). `exact` fixes this for bare names but holds +only bare names. `stemTwins()` finds the qualified names whose stem another +qualified name shares (104 names under 50 stems, mostly a function and its +`$` form: `strings.left` / `strings.left$`), and `qualifiedField()` appends +each twin an entry holds to its `qualified` field as `exactName()` writes it +(`printer_2efonts_`). The query's whole-qualified clause (a word with a dot, +or two adjacent words joined with one, as `exactName()` writes them) matches +it. A whole name that isn't a twin is in no field, so the clause matches +nothing for it. + +**Measured, and rejected** (on every qualified name typed as two words): +- Every qualified name whole in `exact`: 19 MB more heap (293 to 312 MB) for + 14 queries. +- Only the twins whole in `exact`: made the entries holding them longer in + the field bare names are ranked by, so `InStrB` fell from 1 to 2, behind + its own section `Strings/InStr#instr-instrb`, and `MidB$` from 2 to 3 (BM25 + length normalisation). +- The twins whole in `qualified`: the bare-name ranking can't move, and the + result held at clause boosts from 1 to 100. Shipped at 10; heap 293.1 MB. + +### lunr patches + +All installed once on the global `lunr`, from the tokenizer wrapper and +install block in both `initSearch()` copies and in the replica's +`loadLunr()`. The failure each patch fixes is described under "How lunr +behaves here". + +- **Stop words kept** (Design §5 A). +- **The tokenizer wrapper** splits runs of two or more dots (Design §5 B) and + decodes entities. +- **`separateTokenSetKeys()`** replaces `TokenSet#toString()` with a key that + has a `,` after each child id. lunr 2.3.9's key made two different nodes + collide, so the token set held words no entry has (`amp;h80004001010`) and + lost real ones; a trailing-wildcard clause reaching an invented word threw, + which any query with the word `a` did. Whether keys collide depends on the + whole term set, so any content change can start it; check a candidate change + with every page and section title as a query (3,658 titles, none throws). + `toString()` is used only for these keys. The guard takes lunr's own + `toString()` from `lunr.min.js` and checks that a 494-word fixture built from + id 3 still collides with it, so the test says when a lunr upgrade changes + this. +- **`accumulateSetUnions()`** replaces `Set#union` with one that, once it has + made a set, adds the next set into it in place. lunr's `Index#query` unions + once per expanded term and field of a REQUIRED clause, copying both sets + each time, and a short word's wildcard expands to thousands of terms: + quadratic. lunr's only unions are running totals that drop the set they + replace, so nothing sees the change. It keeps lunr's own `length` (the first + set's elements plus the second's, an element in both counting twice), which + `intersect()` uses to choose the set it walks, so even the order of a set's + elements is lunr's. Results, refs and scores are unchanged, checked over + 10,292 queries, one per page or section title; they took 718 s in the + replica before (worst 4,068 ms) and 86 s after (7 over 200 ms, worst 331 + ms), and `a page` fell from 809 ms to 87 ms. The token-set guard's install + check allows a second install after `separateTokenSetKeys()`. +- **`pinIndexFieldLengths()`** pins the `index` field's average length at 1. +- **`buildIndexInSlices()`** builds the index in slices (Design §5 C). + +**Entities.** The search data keeps the page's HTML entities (`&`, +`>`, `<`, `-`), so the index held `amp;h80004005` while a query +trims to `h80004005`, and `&H80004005` found none of the five pages that +mention it. The data can't decode them: the client inserts `doc.title`, +`doc.doc` and slices of `doc.content` with `innerHTML` and highlights by +lunr's character positions in that text, so a decoded `<Object>` would +become a tag in the results panel and every highlight after an entity would +shift. Instead the tokenizer wrapper passes each token through +`decodeTokenEntities()`, which decodes `&`, `<`, `>`, `"`, +`'`, ` ` and decimal and hexadecimal references, lowercased like the +rest of the token. `Token#update` keeps the token's position, which still +spans the escaped text, so the highlight covers `(&H80004005)` and shows +`(&H80004005)`. The same wrapper runs on queries, where it changes nothing a +reader types. Limits: a decoded separator doesn't split its token +(`per-lane` indexes as `per-lane`, which a query splits into two words); +punctuation inside a token still blocks a match (`Emit(&Hb8,` indexes as +`emit(&hb8`), as for any text; and the operator characters the entities spell +(`<`, `>`) still trim away, so operators stay with their exact names (see +"Operators"). + +**Cost left.** The remaining cost of a one-letter word (`a p` about 250 ms, a +single letter 80–110 ms, in the replica) is lunr's own work (43% in +`Index#query` itself): each one-letter word +expands to thousands of terms in three clauses. Making it cheaper means +changing the query, for instance not completing a one-letter word, and so the +ranking; that needs its own measurement and the user's call (Next, item 3). + +### Hand-marked index entries Entries live in the page, not a central file, so an entry moves with its text and a renamed heading can't strand it. A page takes `index:` / `index_also:` @@ -752,10 +1071,14 @@ takes both attributes off every heading into `env.searchIndexMarks` as `{ id, index, index_also }`, and throws on either attribute anywhere else, since it would be published and do nothing. `search.mjs` gives each section the ids of every heading it holds (its own, deeper ones and folded ones), -attaches heading marks by id and frontmatter marks to the page's own entry, -and emits `index` / `index_also` as JSON lists. On main, `checkIndexTerms` -throws when two entries claim one term as `index` (compared without case, a -hyphen counting as a space). +attaches heading marks by id (an error if no entry holds that heading) and +frontmatter marks to the page's own entry, keeps terms in the order written, +once each regardless of case, drops a main term from the same entry's +secondary ones, and emits `index` / `index_also` as JSON lists. On main, +`checkIndexTerms` throws when two entries claim one term as `index` (compared +without case, a hyphen counting as a space). The client also stems, so `late +bindings` and `late binding` would still collide there without failing the +build: keep terms in their plain form. **Client**, in all three copies: - One lunr field `index`, boost 1000. Each term is one token: its words @@ -764,616 +1087,181 @@ hyphen counting as a space). - The query adds, for every run of up to four consecutive words, that run's key at clause boost 5 and the key plus `_` at boost 1, on `index` only. So a term matches only a query that names all of it, alone or among other - words: `binding` alone doesn't match `late binding`. + words: `binding` alone doesn't match `late binding`. Main and secondary + therefore weigh 5 to 1. - The terms are appended to the entry's content as plain words, so the - all-words-first pass, which requires every word in the text fields, still - finds a marked entry. -- `pinIndexFieldLengths` sets the field's average length to 1 (see "How lunr - behaves here"). - -**The first three entries** (placed by reading the pages; existing links were -leads only): + all-words pass, which requires every word in the text fields, still finds a + marked entry. +- `pinIndexFieldLengths` sets the field's average length to 1: BM25 compares + a field's length with its average over every entry, and `index` is empty on + nearly every entry, so a marked entry looked a thousand times too long. -| term | `index` | `index_also` | -|---|---|---| -| conditional compilation | `/tB/Core/Topic-Preprocessor` (page) | `/Reference/Compiler-Constants` (page) | -| late binding | `/Reference/Data-Types#object` | `/tB/Modules/Interaction/CreateObject` (page) | -| 64-bit compilation | `/Features/64bit` (page) | | - -`symbol index` needed none: the user ruled that Permanent-Links' definition -counts as well as Building's section, and the definition ranks first on its -own text. - -**Measured:** -- With two fields and no length pin, the entries barely moved anything - (`late binding` 5 → 2, the other two unchanged): BM25's average length for - a nearly empty field. With the pin, all three went to rank 1. -- Boosts, main/secondary: 1000/200 and 200/50 both put the three at rank 1, - but only 1000/200 keeps CreateObject right behind Data-Types for `late +The boosts sit in the client beside every other boost, not in +`docs/_config.yml`: the client reads no site config. + +**Measured, and rejected:** +- Two fields (main, secondary) with no length pin: the first three entries + barely moved (`late binding` 5 to 2, the other two unchanged). With the pin + all three went to rank 1. +- Boosts, main/secondary: 1000/200 and 200/50 both put them at rank 1, but + only 1000/200 keeps CreateObject right behind Data-Types for `late binding` (at 200/50 the `Bind` method comes between). 100/20 left `late binding` at 2; 50/10 and 20/5 fixed nothing. -- Weighting longer word runs higher changed nothing, so it was dropped. -- Two fields plus a words field cost 24 MB of heap (288 → 312 MB); the words +- Weighting longer word runs higher changed nothing. +- Heap: two fields plus a words field cost 24 MB (288 to 312 MB); the words in `content` brought that to 10 MB, and one field for both levels to 5 MB, - with identical results. -- Result: 0 queries worse, 3 better; index build +3–8%, heap 292 MB. - -The boosts sit in the client beside every other boost, not in -`docs/_config.yml`: the client reads no site config. `test/search.test.mjs` -covers the marks' path from page to entry, the render rule, the one-main-entry -check, and the client patch by behaviour. - -### What shipped, fourth round: qualified names - -432 qualified queries missed rank 1: 338 at 2–5, 92 at 20–27. Every entry -already held the right token (`qualified` keeps `FileListBox.Name` whole, and -the query keeps it whole beside its parts), so this was weighting, not recall: -- **The container's page came first** (333, most at rank 2): - `FileListBox.Name` → the FileListBox page, whose title matches - `filelistbox` at boost 200, above `#name`, whose `qualified` match counted - at 50. -- **Headings naming several members fell furthest**: `Slider.KeyDown`, under - `KeyDown, KeyPress, KeyUp`, came 20th, behind every other control's single - `KeyDown`, since BM25 discounts its three-name title and `names`. - -Measured with knobs in the replica: - -| `qualified` boost | plain words complete in `qualified` | hit@1 | worse / better | -|---|---|---|---| -| 50 (before) | yes | 94.18% | | -| 200 | yes | 98.83% | 1 / 421 (`vbFile` 1 → 2) | -| 1000 | yes | 99.41% | 4 / 423 (`error handling` 1 → 3, `System`, `TextAlign`, `vbFile`) | -| 300 | no | 98.93% | 0 / 423 | -| 500, 700, 1000 | no | 99.46% | 0 / 423 | - -The regressions all came from one mechanism: a plain word completing in -`qualified` by the trailing wildcard, to every member of each container whose -name it begins (`vbfile*` to all of `vbfileattribute.*`). At boost 50 that -was noise; at 500 it pulled a container up by the number of its members. So -only a token with a split dot may complete there. - -The eval's symbol queries are one word each, so two-word queries were checked -separately, on probes (`Form events`, `ListView events`, `TextBox properties`, -`Printer object`, `DTPicker format`, `Slider value`, `FileListBox Name`, -`Debug Print`, ...) and on every qualified symbol written as two words -(`FileListBox Name`, 5108 queries, not saved as ground truth). The boost alone -lost a good section from the top three on five probes, through the same -mechanism in the all-words pass's REQUIRED clauses (`form*` completing to -every `form.*`): - -| two-word variant | spaced hit@1 | worse / better than before | probes | -|---|---|---|---| -| before | 97.75% | | | -| boost 500, REQUIRED unchanged | 98.84% | | 5 worse | -| REQUIRED off `qualified` | | 36 gone from the results | fixed | -| REQUIRED over all, scored off `qualified` | 96.93% | 45 / 0 | fixed | -| the same, plus word pairs | 99.82% | 0 / 106 | fixed or better | -| the same, pairs also completing | | | `File I/O` 1 → 2 | - -Taking `qualified` out of the REQUIRED clauses lost entries that name their -container nowhere else (`_HiddenModule Input`, `CefEnvironmentOptions -LogFilePath`), so presence and score are split: a REQUIRED clause over every -text field at boost 0, which only decides who qualifies, and a second clause -that scores the word off `qualified`. lunr ORs a REQUIRED clause's fields and -adds boost-0 terms to the query vector at zero weight, so this changes no -presence and no other score. Without `qualified`, two words naming a member -(`FileListBox Name`) had nothing tying them together, so every two adjacent -plain words are also a term on `qualified`, joined with a dot, at clause -boost 10. - -**Shipped**, in all three copies: `qualified` at boost 500; plain words -complete in the text fields less `qualified`; word pairs on `qualified`; the -REQUIRED split. Result: hit@1 94.18% → 99.46%, qualified hit@1 99.8%, two-word -qualified 99.82%, 0 queries worse, no new field or term (search-data and heap -unchanged). `test/search.test.mjs`'s qualified-name guard checks the online -client's clauses by pattern and the replica by behaviour; mutations (boost 50, -plain words completing in `qualified`, no pairs, the REQUIRED clause scoring -in or dropped off `qualified`) each fail it. - -The stem collisions and the entries with no `#shape` / `#timer` are handled in -[Fixed: stem twins](#fixed-stem-twins) and -[Fixed: a member heading taken for the page title](#fixed-a-member-heading-taken-for-the-page-title). - -### Fixed: a member heading taken for the page title - -`extractSections` takes only the page's first heading for the title, when it -reads the same as the page's title and has no prose before it: that entry gets -the page's URL and no prefix entry is made. A later heading that reads the same -is a member. On Shape and Timer the h1 is "Shape class" / "Timer class", so -`### Shape` and `### Timer`, which document the Shape and Timer properties, -keep their own entries (`#shape`, `#timer`), and `Shape.Shape` and -`Timer.Timer` are found. - -The rule matters for four pages' entries: Shape and Timer, and two prose pages -whose h1 differs from the title and whose second heading repeats it -(`Features/Standard-Library/New-Functions`, `Challenges/1`). Those two have an -empty page entry beside the section, as 272 other pages do (every page whose h1 -differs from its title). - -### Fixed: stem twins - -`Printer.Font` and `Printer.Fonts` both stem to `printer.font`, so in -`qualified`, and everywhere else a query for either reached, the two tied; -`Printer.Fonts` came second. So did `Collection.Item`, `Global.Printers`, -`OLE.Update`, `Report.Page` and both `WebView2*Headers.GetHeaders`, and the -same seven written as two words (`Printer Fonts`). For bare names `exact` had -fixed this, but its clause matched nothing for a qualified name: `exact` held -only bare names. - -Measured with knobs in the replica, and on the throwaway set of every -qualified name written as two words: - -| variant | worse / better | two words, hit@1 | heap | new terms | -|---|---|---|---|---| -| before | | 99.86% | 292.8 MB | | -| every qualified name whole in `exact` | 0 / 7 | 99.86% (no clause for two words) | | 5108 | -| the same, plus word pairs whole on `exact` | 0 / 7 | 100% | 311.6 MB | 5108 | -| only the stem twins whole in `exact`, plus pairs | **2** / 7 | 100% | 293.1 MB | 104 | -| only the stem twins whole in `qualified`, plus pairs (clause boost 1, 10 or 100) | 0 / 7 | 100% | 293.1 MB | 104 | - -- Every qualified name whole cost 19 MB of heap for 14 queries. -- Only the twins, but in `exact`, made the entries holding them longer in the - field bare names are ranked by. `InStrB` fell from 1 to 2, behind its own - section `Strings/InStr#instr-instrb`, and `MidB$` from 2 to 3, both BM25 - length normalisation. -- In `qualified` the bare-name ranking can't move, and the result held at - clause boosts from 1 to 100. - -**Shipped**, in all three copies: `stemTwins()` finds the qualified names -whose stem another qualified name shares (104 names under 50 stems, mostly a -function and its `$` form: `strings.left` / `strings.left$`), and -`qualifiedField()` appends each twin an entry holds to its `qualified` field -as `exactName()` writes it (`printer_2efonts_`). The query adds, on -`qualified` at clause boost 10, every word with a dot in it and every two -adjacent words joined with a dot, as `exactName()` writes them. A whole name -that isn't a twin is in no field, so it matches nothing. - -Result: qualified hit@1 100%, every qualified name as two words 100% (0 -worse); heap 293.1 MB, 26,133 index terms; search-data unchanged. -`test/search.test.mjs` checks all three copies fill `qualified` through -`qualifiedField()`, compares the online client's `stemTwins()` and -`qualifiedField()` with the replica's by behaviour, and ranks twins in a small -index both ways. - -Probes with known odd results, unchanged by this: `Form events` puts -`/tB/Core/Event` first and `Form#events` second; `Fonts property` puts -`AmbientProperties/Font` first. - -### Same-page sections count - -Where a symbol's URL is a page, not a section of one, any section of that -page counts for it too. `DefInt` is documented by the Deftype page, and its -section headed `DefBool, DefByte, DefInt, ...`, which ranked first, lands the -reader on the same definition; so do `Chr#chr-chrb-chrw` for `ChrB` and -`Left#left-leftb` for `LeftB`. `eval/search_quality.mjs` judges by this, as -ground truth `intent-3`; it applies to bare and qualified names alike, and -not to prose, which already matched by path. It moved 24 queries from rank 2 -to rank 1 and nothing else. - -`MidB$` stays a miss, rightly: its first result is the `MidB =` statement -(`/tB/Core/MidB-equals`), a different page, and the Mid function it names is -second. - -### Fixed: lunr invented words - -The replica threw inside lunr (`Cannot read properties of undefined (reading -'_index')`) for 103 of 3,658 page and section titles, every one holding the -word `a` (or `&`). So did both real clients: typing `a` left the results -empty and logged the error. - -lunr's index keeps a token set of its terms for wildcard and fuzzy matching. -Built from this site's terms, it held `amp;h80004001010`, which no entry has, -and lacked `amp;h80004001` and `amp;h80004005`, which six pages have. A -trailing-wildcard clause reaching the invented word (`a*`, `am*`, `amp*`) -found no postings for it and threw. - -The cause is lunr 2.3.9's `TokenSet.Builder#minimize`, which merges nodes -whose `TokenSet#toString()` match. That key is the final flag, then each -edge's label and its child's numeric id with nothing between: `{1 -> 656}` -and `{1 -> 6, 5 -> 6}` (6 being the shared leaf) both key as `01656`. Whether -keys collide depends on the whole term set and on the ids nodes happen to -get. Rebuilding the token set with a `,` after each id gives exactly the -index's terms. - -**Shipped**, in all three copies: `separateTokenSetKeys()` replaces -`TokenSet#toString()` with the separated key, installed once beside the -tokenizer wrapper. `toString()` is used only for these keys. Result: 0 of -3,658 titles throw; the token set matches the index terms. - -`test/search.test.mjs`'s token-set key guard takes lunr's own `toString()` -from `lunr.min.js`, checks that a 494-word fixture built from id 3 still -collides with it (so the test says when a lunr upgrade changes this), and -that the online client's and the replica's keys keep the fixture exact. - -### Fixed: whole titles - -Two probe sets were built from the site (`eval/search-experiments/probes/`), -then shipped as ground truth `intent-4`. - -**Diagnosis.** -- `New Functions`: `ServiceState#new` (689) above the page (437). `functions` - is a kind word, so the query names `New`, and the exact-name clause matches - `new_` in the method's `exact`. The page matches both words in its title and - `page`. -- `Form events`: `/tB/Core/Event` (210) above `Form#events` (151). The - exact-name clause (`form_`) plays no part: the Form page's own entry lacks - `event`, so the all-words pass drops it. `events` stems to `event`, the - Event page's title (200) and name (100); `Form#events` has `form` only in - `page` (5) and its URL. - -**How widespread:** -- *titles*: every page's own multi-word title typed as is (149): 84.6% at - rank 1. The misses are a one-word symbol entry beating a page titled with - both words (`Return Syntax` → `Return`, `Delegate Types` → `Delegate`), made - worse where the other word is a kind word. -- *sections*: `
` for the section titles 20+ pages - share, such as `DTPicker Properties` (300): 22.7% at rank 1, 94.3% in the - top three. In 182 of the 232 misses the page's own `X class` heading wins: - it has `X` in its title (200), the section only in `page` (5). - -**Measured**, both sets and every qualified name as two words (*spaced*): - -| variant | eval | titles hit@1 | sections hit@1 | spaced | worse anywhere | -|---|---|---|---|---|---| -| now | | 84.6% | 22.7% | 100% | | -| plural kind words don't make a name | unchanged | 85.2% | 22.7% | 100% | 0 | -| `page` boost 10 / 20 / 50 | `Pointer` 6→7 (50: also `MidB$` 2→3) | 85.2–85.9% | 22.7–23.7% | 100% | 1–3 | -| whole title ×1.5 | unchanged | 90.6% | 55.7% | 100% | 0 | -| whole title ×2 | unchanged | 91.9% | 85.0% | 100% | 0 | -| whole title ×3 | unchanged | 91.9% | 95.0% | 100% | 0 | -| whole title ×5 / ×10 | unchanged | 94.0% | 96.7% | 99.59 / 99.55% | 21 / 23 spaced | -| **plural + whole title ×3** | unchanged | **94.0%** | **96.0%** | 100% | **0** | - -*Whole title*: after lunr ranks, a query of two or more words multiplies the -score of every result whose title, or page title plus title, reads the same -as the query (compared as `indexTermKey()` writes both: tokenized, trimmed, -stemmed). It adds no index term and no field; the keys are computed once per -entry, on first use. At ×5 it goes wrong: `_App Comments` lifts the `App` -page's Comments section, since the trimmer drops the leading `_`. ×3 leaves a -margin. Latency: about 100 ms more on the first multi-word query after the -index is built (computing the keys of its results), nothing measurable after. - -**Shipped.** -1. The two sets as ground truth `intent-4` in `eval/search_quality.mjs`, with - no ranking change: - - *page title*: every page's own title of two or more words, as the - reader sees it (entities decoded: the data holds `<<`); any entry - of that page counts. 140 queries. The 9 operator pages (`&, &=`, - `<<, <<=`) are left out, since a reader types one operator, which the - bare-name set measures. - - *page plus section*: `
` for the one-word - section titles 20+ pages share, less sections that document a symbol; - only that section counts, and a query several sections share expects - any of them. 300 queries. -2. The ×3 re-rank, `boostWholeTitles()`, at the end of `doSearch()` in - `just-the-docs.js` and of the replica's `search()`. `doSearch()` is outside - `initSearch()`, so the offline copy shares it: two copies, not three. The - replica needs `docs` in its context (`load()` gives it; the tests' - fixtures pass it too) and fails loudly without it, since a replica that - skipped the re-rank would quietly differ from the client. -3. A kind word counts only in the singular. - -Result: page title 87.9% → 97.9%, page plus section 22.7% → 96.7%; other -queries unchanged; 249 better, none worse. Every page and section title -(3,658) runs as a query without throwing. `test/search.test.mjs`'s -whole-title guard runs both copies' `boostWholeTitles()` on the same results, -checks both apply it to the final results, and ranks a fixture like the site -(`DTPicker Properties` behind the class heading without the boost). The query -guard checks that both compare kind words as typed. - -Left, 3 page titles and 10 page-plus-section queries, each at rank 2 or 3: -- `Mid =`: the trimmer drops `=`, so the query is `Mid`, one word, and the - Mid function (tier 1 for `Mid`) comes before the `Mid =` statement. An - operator-like title, like those left out of the set. -- `Compiler and IDE Features`: the Features page's section of the same title, - then the page. Both read the same as the query. -- `WebView2 Package`: `/tB/Packages/WebView2/WebView2`, then the package page. -- Sections behind a page whose name contains or stems like theirs: - `Printers Properties` behind `Printer#properties`; the six `Html*` pages - (`HtmlElement Properties` behind the same page's second Properties section, - which documents the `HtmlElement.Properties` member, so the set leaves it - out, and behind `HtmlElements#properties`); `UpDown Properties` behind - `DTPicker#updown`; `ParentControls Members` behind - `UserControl#parentcontrols`; `Timer Properties` behind the Timer function. - -Not diagnosed further: none is a reader's likely query in a form the bare-name -or qualified sets don't already cover, and each would need its own tweak. - -Open: the re-rank works outside lunr's scoring, as a post-pass. Whether a -hand-marked index entry should outrank it if the two ever disagree is -untested: no prose query has a heading of the same text elsewhere. - -### Fixed: entities in the index - -`&H80004005` found none of the five pages that mention it, only an unrelated -fuzzy match. The search content keeps the page's HTML entities (`&` 991 -times, `>` 977, `<` 819, `-` 90, a few others), so the index held -`amp;h80004005` while the query trims to `h80004005`. 507 index terms held an -entity. - -**Not a content fix.** The client inserts `doc.title`, `doc.doc` and slices -of `doc.content` with `innerHTML`, and highlights by lunr's character -positions in that text. Decoded in `search-data.json`, `<Object>` would -become a tag in the results panel, and every highlight after an entity would -shift. - -**Shipped**, in all three copies: the tokenizer wrapper (installed in both -`initSearch()` copies and the replica's `loadLunr()`) passes each token -through `decodeTokenEntities()`, which decodes `&`, `<`, `>`, -`"`, `'`, ` ` and numeric references, lowercased like the rest -of the token. `Token#update` keeps the token's position, which still spans -the escaped text, so the highlight covers `(&H80004005)` and shows -`(&H80004005)`. The same wrapper runs on queries, where it changes nothing a -reader types. Result: `&H80004005` finds the 5 pages and nothing else; index -terms 25,897, none with an entity; eval and spaced sets unchanged. - -`test/search.test.mjs`'s entity guard checks that all three wrappers call the -decoder, that the client's and the replica's decode alike and keep positions, -and that the replica finds and highlights the literal. The client lowercases -the decoded token (`A` decoded to `A` while every other token is -lowercase). - -Limits: a decoded separator doesn't split its token (`per-lane` indexes -as `per-lane`, which a query splits into two words); punctuation inside a -token still blocks a match (`Emit(&Hb8,` indexes as `emit(&hb8`), as for -any text; and the operator characters the entities spell (`<`, `>`) still -trim away, so operators stay with their exact names (see "Operators"). - -### Fixed: slow multi-word queries - -`a page` took about 800 ms per search in the replica and `a p`, a keystroke on -the way to it, 1.3 s. - -**Profiled** (`node --cpu-prof`, `a p` and `a page`): 82% of the time in -`lunr.Set#union` and the `lunr.Set` constructor. lunr 2.3.9's `Index#query` -gathers the entries a REQUIRED clause matches as a running total, `c = -c.union(S)`, once per term the clause expands to and per field, and `union` -copies both sets into a new one every time. The all-words pass requires every -word as its stem with a trailing wildcard, on six fields, and `a*` expands to -thousands of terms: quadratic. - -**Shipped**, in all three copies: `accumulateSetUnions()`, installed beside -`separateTokenSetKeys()`, replaces `Set#union` with one that, once it has -made a set, adds the next set into it in place. lunr's only unions are -running totals that drop the set they replace (`c`, the prohibited sets, and -the final `R`), so nothing can see the change. It keeps lunr's own `length` -(the first set's elements plus the second's, an element in both counting -twice), which `intersect()` uses to choose the set it walks, so even the -order of a set's elements is lunr's. - -| | before | after | -|---|---|---| -| replica, `a page` | 809 ms | 87 ms | -| replica, `a p` | 1,334 ms | 278 ms | -| replica, `Form events` / `the form` / `to the` | 104 / 112 / 114 ms | 52 / 35 / 43 ms | -| Chrome, online client, `a page` / `a p` | 501 / 1,102 ms | 120 / 235 ms | -| Chrome, `Form events` / `the form` | 70 / 86 ms | 17 / 41 ms | -| every page and section title as a query (10,292), replica | 718 s; 716 over 200 ms; worst 4,068 ms | 86 s; 7 over 200 ms; worst 331 ms | - -Results, refs and scores are identical for all 10,292 title queries; the eval -is unchanged. (Chrome: one run each on the same page.) `test/search.test.mjs`'s -set-union guard runs the online client's and the replica's `union` against -lunr's own (extracted from `lunr.min.js`) on a chain of sets: the same -elements and length at every step, inputs untouched, and the running total -added to in place. It also ranks a fixture with both and checks all three -copies install it. The token-set guard's install check allows a second -install after `separateTokenSetKeys(lunr)`. - -**Left**, none a regression: `a p` still takes about 250 ms and a single letter -(`a`, `t`) about 80–110 ms. That is lunr's own work (43% in `Index#query` -itself): each one-letter word expands to thousands of terms in three clauses. -Making it cheaper means changing the query, and so the ranking, for instance -not completing a one-letter word; that needs its own measurement and the -user's call. - -### Fixed: kind words - -`continue statement` didn't find `/tB/Core/Continue`, whose page never says -"statement". The query names `Continue` plus a kind word, which the exact-name -clause handles, but the all-words pass still *required* the kind word, so the -page was never a candidate. `With statement` worked only because its page -happens to use the word. - -**How widespread**: every symbol written as its name and its kind, for the -kinds the client counts as kind words (1,836 queries, -`eval/search-experiments/probes/kinds.mjs`): 61.6% at rank 1, and 662 not -found at all. A member's section rarely says "property", "method" or "event": -`MaxHeight property`, `Terminate event`, `VbTriState enum`. The eval never saw -it, since its symbol queries are bare names. - -**Measured** with knobs: - -| variant | kinds hit@1 | kinds worse | eval | spaced (5,108) | -|---|---|---|---|---| -| now | 61.6% | | | 100% | -| kind words never required | 93.9% | 22 (`Mid function`, `Line statement`, `Timer event`) | `Loop Control` 1→2 | 3 worse (`File Type`) | -| optional only when no result has the exact name | 94.7% | 4 (`Mid function`, `Stop method`) | | | -| **optional only when no result has the name in its title or as its exact name** | **91.1%** | **0** | unchanged | unchanged | - -Where requiring the word worked, it kept a different kind of the same name -out: `Mid function` finds a section of the Mid page, titled `Mid`; with the -word optional, the `Mid =` statement page, which never says "function", comes -in on its exact name. The exact-name test alone fell back there, since a -section carries no names. - -**Shipped**, in the client (`doSearch()`, so the offline copy shares it) and -the replica: for a query naming one thing in two or more words, if no entry -the all-words pass found matched the name in `title`, or in `exact` or -`primary` (`namesTheThing()`), the pass runs again with the kind words -optional; they still score. Result: name and kind 61.6% → 91.1% hit@1 (top 10 -94.9%, not found 93); 577 better, 0 worse; eval and spaced sets unchanged. -`test/search.test.mjs`'s kind-word guard runs both copies' `namesTheThing()` -on the same results, checks both fall back the same way, and ranks a fixture -with the `MaxHeight` and `Mid` shapes. - -**Left**: 164 not at rank 1, 93 not found, mostly properties (75). Not -diagnosed further. - -**Ground truth `intent-5`.** The user agreed to make the set ground truth in -`eval/search_quality.mjs` (category `name and kind`), less `sub` and -`member`: readers don't say them (a Sub is a method to them). Spelling a Sub -as `method` instead would need another query pass, which lunr's cost rules -out for now. Operators, with no word character, stay out, as the bare-name -set measures them. Any symbol of that name and kind counts, and any section of -a page that documents one. Reasons against: the fallback was designed on this -set, so it starts near its best (fine as a regression guard, flattering for -gains); several symbols of one name and kind (`Name property`) are judged -leniently, any of them counting; and at 1,785 queries it moves the overall -hit@1 (99.7% → 98.2%), so compare categories, not the total, across ground -truths. `KIND_WORDS` is exported from the replica, so the eval and -`probes/kinds.mjs` build the set from the list the replica searches with. - -### Item 6: shipped - -Two Sonnet agents drafted candidate terms: one from the glossary, one from -jargon and features outside it. Both read the target pages; every proposed -URL was checked to exist in the build and the contested pages were reread. -The user approved every target. The agents found no doubtful links; two soft -notes: Project-Types' Kernel-Mode Drivers section names the "Native -subsystem" setting without linking it, and twinBASIC-Additions' changelog -anchors outrank the Features pages for `inline assembly`. - -**Rulings.** -- *A Glossary definition counts as a right answer for its term, and needn't - be first: within the top 5 is enough.* The ten ` data type` terms, - `Function procedure`, `Sub procedure`, `Property procedure`, `breakpoint`, - `watch expression`, `base class`, `compiler directive`, `MDI form` and - `dynamic-link library` are all at rank 1 by their Glossary definitions, so - they need no entries. -- *Bare words that name a language element*: `array` (the `Array` function - first, `/Tutorials/Arrays` second) and `delegates` (`/tB/Core/Delegate`, - then `/Features/Language/Delegates`) get no entry. An index term shares its - stem with the bare name (`deleg_`; the stem can't tell singular from - plural), so an entry for `arrays` or `delegates` made the concept page first - and the element second for `array`, `Array`, `arrays`, `delegate`, - `Delegate`, `delegates`, and made the eval 2 worse (bare `Array` and - `Delegate`, 1 → 2). `array`: the Glossary's definition is 4th. `delegate` - and `delegates`: the Glossary has no entry for the word; the user was asked - what to add. -- All 16 recommended rows were approved, with shortened forms indexed too - (`register COM dll`, `register dll`, `create dll`) and more variants: - `inline initialization`, `field initialization`, `typedecl char[acter]`, - `type char[acter]`. Capitals don't matter. -- Decided by the user: `user defined types` (and `user types`) → - Type, then UDTs; `event handlers` → Handlers, then the Forms tutorial's - handler step; `namespaces` → a new Glossary definition that links the - relevant pages, then the Packages page; `twinpack` → Creating-TWINPACK, then - the other package pages; `standard exe`, `create an ActiveX DLL`, `create - ActiveX DLL` → the New Project dialog's options (main entry), with Project - Settings' *Build Type* and Project-Types as secondary entries (Project-Types - covers types "beyond the traditional EXE and ActiveX DLL/Control"); `pointers` - → a secondary entry ("at a lower priority"): the Pointers page goes 3 → 1 - and bare `Pointer` is untouched (a main entry cost it 6 → 7), but `Pointer - field` goes 3 → 4, kept at the user's request. -- Not indexed (the right pages are already in the top 3 or first): `continue - statement` (fixed by the kind-word tweak), `packages`, `inline assembly`, - `import a vbp project`, `breakpoints`. -- Content gaps that item 7 then handled or left: multiple return values, - application manifest, by reference / by value, ActiveX control, Automation - object, Object Browser, type library, named arguments, tab order, twips. -- 53 queries already at rank 1 (41 jargon, 12 glossary: `generics`, - `multithreading`, `static linking`, `migrate from VB6`, `unit testing`, `data - type`, `date literal`, `control array`) could join the prose set as guards: - they don't discriminate, but they would catch a regression. Not done. - -Each batch went in as queries first, then entries. Result, prose set (it now -holds 108 queries): - -| | prose hit@1 | `behind` within 3 | worse | -|---|---|---|---| -| the 20 earlier queries | 20 of 20 | 1 of 1 | | -| + 33 approved, before entries | 25 of 53 | 1 of 10 | | -| with their entries | 50 of 53 | 10 of 10 | 0 | -| + 11 decided, before entries | 51 of 64 | 10 of 17 | | -| with their entries | **61 of 64** | **17 of 17** | 0 | - -Qualified names typed as two words are unchanged throughout. - -**Rules the measurements forced** (also under "Rules for index entries"): -- *A term whose stem is a bare name's reorders that name.* `declaration` - (stem `declar`) put `Declare` 2nd; `comment` put the `Comments` property - 2nd; `pointers` cost `Pointer` 6 → 7 and `Pointer field` 3 → 4. So there is - no entry for `declaration` and `comment`: their Glossary definitions are - 2nd and 5th without one, which the ruling counts, and their queries accept - the Glossary. `pointers` has no Glossary entry; its page is 3rd. -- *A secondary entry can overtake a page that ranks first only on its own - text.* CommandButton's `access key` put it above Label, and UDTs' `user + with identical results. Hence one field, with the secondary level encoded + as one more `_`. + +### Index entry decisions + +The rules for writing entries are under "Rules for index entries" above. The +decisions behind them, and what the measurements ruled out: + +- **A Glossary definition counts as a right answer for its term, within the + top 5.** A term whose Glossary definition already ranks first gets no + entry. +- **A term whose stem is a bare name's reorders that name, so there is no + entry for it.** `declaration` (stem `declar`) put `Declare` 2nd; `comment` + put the `Comments` property 2nd; their Glossary definitions are 2nd and 5th + without an entry, which the ruling counts. `array` and `delegates`: an + index term shares its stem with the bare name (`deleg_`; the stem can't tell + singular from plural), so an entry for `arrays` or `delegates` made the + concept page first and the element second for `array`, `Array`, `arrays`, + `delegate`, `Delegate` and `delegates`, and made the eval 2 worse (bare + `Array` and `Delegate`, 1 to 2). +- **`pointers` is a secondary entry** ("at a lower priority"): the Pointers + page goes 3 to 1 and bare `Pointer` is untouched (a main entry cost it 6 to + 7), but `Pointer field` goes 3 to 4, kept at the user's request. +- **A secondary entry can overtake a page that ranks first only on its own + text.** CommandButton's `access key` put it above Label, and UDTs' `user types` would have put it above Type. The page meant to be first takes a main entry for the term too. -- *A one-word term matches every query holding the word.* The Packages page's - `twinpack` entry beat `Importing a Package from a TWINPACK File` typed - whole; a secondary entry on the Importing page restored it. -- *A term matches only whole words.* `type character` doesn't match `type - char`, so each spelling a reader types is its own term. - -**Limit, not fixed**: a question-shaped query misses the entries. `how do I -register a com dll` finds the FAQ, not ActiveX Registration: the all-words -pass requires `how`, `do` and `I` too, and only the FAQ holds them all. - -### Item 7: content and index entries from two surveys - -Two Sonnet agents rechecked item 6's content gaps and ran about 120 reader -terms (IDE panes, VB6 vocabulary, Features and Tutorials headings) through the -replica. The user asked for every fix they prompted, content included, with -samples checked by the compiler. - -**Rechecked gaps.** Already fine, the Glossary 1st: `by reference`, `by -value`, `ActiveX control`, `ActiveX object`, `Automation object`, `Object -Browser`, `type library`, `tlb`, `named arguments`, `tab order`, `manifest`, -`twips`. Missing, now handled: `ByRef`, `ByVal`, `ByVal vs ByRef` (no section -explained them), `typelib`, `named parameters`, `application manifest`, and -`return multiple values` (no page at all). `ByRef` and `ByVal` are not -symbols, so entries for them reorder no bare name. The survey agent's -"already fine" list was wrong at least once (`optional parameters` landed on -Compiler-Options): rerun a list before using it as guards. - -**Content**, each sample `check_build`, compiled, and its printed values -confirmed with `tbrun`: -- Sub: *Passing arguments ByRef and ByVal*; *Optional arguments and default - values*. Function: *Returning more than one value* (ByRef parameters, a UDT, - an array). Call: *Named arguments* (a positional argument after a named one - is TB5103, checked), and its old example, inert and declaring the 16-bit - `"User"` library, now compiles. -- Polish: Glossary links to these; the Project Explorer's manifest section - says what a manifest does and links Force DPI Awareness; Library References - says how to add a reference; the Variables pane names VB6's Locals window. -- *Placing a section can move words between entries.* With *Named arguments* - before Call's *Example*, the Example folded into the new section instead of - the page's top entry, which lost the word "statement": `Call statement` fell - 1 -> 5. Named arguments comes after the Example. - -**Entries**: Sub `ByRef`, `ByVal`, `optional parameters`; Function `return -multiple values`, `multiple return values`; Call `named parameters`; Glossary -`typelib`; Project Explorer `visual styles`; Attributes `packing alignment`; -Categories `registry access`; Project Settings `add reference` (the Project -menu's References secondary) and `high DPI`; IDE-Features `dark mode` (the -Window menu's Theme secondary); the IDE pages `new project dialog`, `find and -replace`, `search and replace`, `memory window/panel`, `variables panel`, -`locals window`, `diagnostics window`, `history panel/window`, `outline -view/window/panel`. - -Prose hit@1: 73 of 108 before (41 new queries, 8 of them guards); 80 of 108 -with the content; **105 of 108** with the entries; 0 worse. - -**`default property`.** A VB6 reader means a class's default member, which -`[DefaultMember]` sets (Attributes). An entry there, main or secondary alike, -puts it above CommandButton's `Default` property for the name-and-kind query -`Default property` (1 -> 2); search ignores case, so the two are one query. -The user chose a Glossary definition instead. Headed *default property*, it -matched the whole query, took rank 1 at ×3 and pushed `Default` to 2, the same -trade. Headed *default member*, with *default property* as the other name and -a link to `[DefaultMember]`, it lands at 5, where a Glossary definition -counts: nothing worse. - -**Open.** `COM interop`: no clear target (Categories' COM and Automation list, -Interfaces-CoClasses, ActiveX Registration). Content gaps: subclassing (only -the FAQ mentions it), IntelliSense (no page names the feature), conditional -breakpoints (the Debug menu shows none; check the IDE first). - -**Doubtful, noted, not changed.** `compile to exe` puts -`TbExpressionService.Compile` first. `dark mode`, before its entry, put the -site's own build docs (Documentation/Development) first: the builder docs are -in the reader's search, and may crowd other site-tooling words. +- **A one-word term matches every query holding the word.** The Packages + page's `twinpack` entry beat `Importing a Package from a TWINPACK File` + typed whole; a secondary entry on the Importing page restored it. +- **A term matches only whole words.** `type character` doesn't match `type + char`, so each spelling a reader types is its own term (`register COM + dll`, `register dll`, `create dll`, `typedecl char`, `typedecl character`). + Capitals don't matter. +- **Decided by the user:** `user defined types` and `user types` go to Type, + then UDTs; `event handlers` to Handlers, then the Forms tutorial's handler + step; `namespaces` to a new Glossary definition that links the relevant + pages, then the Packages page; `twinpack` to Creating-TWINPACK, then the + other package pages; `standard exe` and `create an ActiveX DLL` to the New + Project dialog's options (main entry), with Project Settings' *Build Type* + and Project-Types as secondary entries (Project-Types covers types "beyond + the traditional EXE and ActiveX DLL/Control"). +- **`default property`.** A VB6 reader means a class's default member, which + `[DefaultMember]` sets (Attributes). An entry there, main or secondary + alike, puts it above CommandButton's `Default` property for the + name-and-kind query `Default property` (1 to 2); search ignores case, so + the two are one query. A Glossary definition headed *default property* + matched the whole query, took rank 1 at x3 and pushed `Default` to 2, the + same trade. The user chose a Glossary definition headed *default member*, + with *default property* as the other name and a link to `[DefaultMember]`: + it lands at 5, where a Glossary definition counts, with nothing worse. +- **Placing a section can move words between entries.** A section placed + before a page's folded `Example` takes the Example into its own entry + instead of the page's top entry, which loses its words (a *Named arguments* + section before Call's *Example* took `statement` away: `Call statement` fell + 1 to 5). New sections go after the Example. +- **A survey's "already fine" list is rechecked** before it is used as + guards: one such list was wrong at least once (`optional parameters` landed + on Compiler-Options). +- **A term that is not a symbol reorders no bare name.** `ByRef` and `ByVal` + are not symbols, so their entries are safe. +- **Terms with no entry, because the right page is already first or in the + top 3:** `continue statement` (fixed by the kind-word fallback), + `packages`, `inline assembly`, `import a vbp project` and `breakpoints`. +- **Prose guards, not added:** 53 queries already at rank 1 (41 jargon, 12 + glossary: `generics`, `multithreading`, `static linking`, `migrate from + VB6`, `unit testing`, `data type`, `date literal`, `control array`) could + join the prose set. They don't discriminate, but would catch a regression. + +### Section entries + +- `extractSections` takes only the page's first heading for the title, when + it reads the same as the page's title and has no prose before it: that + entry gets the page's URL and no prefix entry is made. A later heading that + reads the same is a member. On Shape and Timer the h1 is "Shape class" / + "Timer class", so `### Shape` and `### Timer`, which document the Shape and + Timer properties, keep their own entries (`#shape`, `#timer`), and + `Shape.Shape` and `Timer.Timer` are found. +- A page whose first heading does not read as its title, or whose prose comes + before its first heading, gets a prefix entry beside its first section (the + page's URL, the text before the first heading, often empty). + The first-heading rule matters for four pages: Shape, Timer, and two prose + pages whose h1 differs from the title and whose second heading repeats it + (`Features/Standard-Library/New-Functions`, `Challenges/1`). + +### Rejected: tier-specific exact fields (X1t) + +Splitting `exact` into `exact1`/`exact2`/`exact3` by symbol kind, with +descending boosts, measured no better than flat `exact` (hit@1 90.0% against +90.1%) and ordered tiers worse (48 violating queries against 42). It would +also need the build's join to emit each symbol's kind. **This is superseded:** +it was measured while a lone clause's boost cancelled out, so it never had a +fair test. Done with field boosts, as the `primary` field, it took tier-order +violations to zero. Don't rebuild X1t without a new idea. + +The measurement also blamed the CheckBox control's `names` field for not +listing every member, which was false: both entries' `names` are the single +word `CheckBox`. The real difference: the CheckBox page's top entry +(`/tB/Packages/VB/CheckBox/`) has 1 character of content, since a class's +introduction sits under its own heading (`#checkbox-class`), while +`DTPicker › CheckBox` has 467 characters that mention "checkbox" several +times. + +### Known misses and limits + +The counts are in "Resuming this work"; the diagnoses are here. + +- **Three page titles and ten page-plus-section queries**, each at rank 2 or + 3: + - `Mid =`: the trimmer drops `=`, so the query is `Mid`, one word, and the + Mid function (tier 1 for `Mid`) comes before the `Mid =` statement. An + operator-like title, like those left out of the set. `Input #` is the + same. + - `Compiler and IDE Features`: the Features page's section of the same + title, then the page. Both read the same as the query. + - `WebView2 Package`: `/tB/Packages/WebView2/WebView2`, then the package + page. + - Sections behind a page whose name contains or stems like theirs: + `Printers Properties` behind `Printer#properties`; the six `Html*` pages + (`HtmlElement Properties` behind the same page's second Properties + section, which documents the `HtmlElement.Properties` member, so the set + leaves it out, and behind `HtmlElements#properties`); `UpDown Properties` + behind `DTPicker#updown`; `ParentControls Members` behind + `UserControl#parentcontrols`; `Timer Properties` behind the Timer + function. + - None is a reader's likely query in a form the bare-name or qualified sets + don't already cover, and each would need its own tweak. +- **Name and kind**: the misses are mostly properties and not diagnosed + further. +- **Question-shaped queries miss the index entries.** `how do I register a + com dll` finds the FAQ, not ActiveX Registration: the all-words pass + requires `how`, `do` and `I` too, and only the FAQ holds them all. +- **Odd results the qualified-name rules don't change:** `Form events` puts + `/tB/Core/Event` first and `Form#events` second (the Form page's own entry + lacks `event`, so the all-words pass drops it; `events` stems to `event`, + the Event page's title and name); `Fonts property` puts + `AmbientProperties/Font` first. +- **The whole-title re-rank is a post-pass outside lunr's scoring.** Whether + a hand-marked index entry should outrank it if the two ever disagree is + untested: no prose query has a heading of the same text elsewhere. +- **Soft notes from the glossary and jargon survey, not changed:** + Project-Types' Kernel-Mode Drivers section names the "Native subsystem" + setting without linking it, and twinBASIC-Additions' changelog anchors + outrank the Features pages for `inline assembly`. +- **The builder docs are in the reader's search**, and may crowd other + site-tooling words: `dark mode`, before its entry, put the site's own build + docs (Documentation/Development) first; `compile to exe` puts + `TbExpressionService.Compile` first. Noted, not changed. +- **Open content gaps:** `COM interop` (no clear target: Categories' COM and + Automation list, Interfaces-CoClasses, ActiveX Registration), subclassing + (only the FAQ mentions it), IntelliSense (no page names the feature) and + conditional breakpoints (the Debug menu shows none; check the IDE first). ## Future work @@ -1386,7 +1274,7 @@ causes, which still explain why operators need exact names: - **Content.** `stripHtml` leaves `<` and `>` as the entities `<` and `>`, so the literal character never reaches the index. (Entities are - decoded per token, see "Fixed: entities in the index"; the trimming below + decoded per token, see "lunr patches"; the trimming below still applies.) - **Trimming.** lunr's trimmer strips non-word characters from both ends of every token, in the index and in the query. An operator token trims to diff --git a/builder/render.mjs b/builder/render.mjs index a08ce1159..e32adc8d7 100644 --- a/builder/render.mjs +++ b/builder/render.mjs @@ -1267,8 +1267,8 @@ function headerIdPlugin(md) { }); } -// Hand-marked search index entries (WIP.Search.md, "What shipped, third -// round: the index pilot"). A heading's `{: index="..." }` names the terms +// Hand-marked search index entries (WIP.Search.md, "Hand-marked index +// entries"). A heading's `{: index="..." }` names the terms // a reader looks up to find that section, as a book's index would, and // `{: index_also="..." }` the terms it is a strong second answer for. Both // are lifted off the heading here, once header-id has given it its id, into diff --git a/builder/search.mjs b/builder/search.mjs index 650c174b8..39022208d 100644 --- a/builder/search.mjs +++ b/builder/search.mjs @@ -19,16 +19,15 @@ // per-chunk entries exist -- see `joinSymbolsToEntries` below and // WIP.Search.md's "Design" §2. They are two fields rather than one // `symbols` field because BM25 discounts a match inside a long field: -// keeping bare names (client boost 100) separate from the longer -// `Container.Name` forms (boost 50) measured better than one field at -// any single boost. +// keeping bare names separate from the longer `Container.Name` forms +// measured better than one field at any single boost. The client's boosts +// for them (and for the other fields) are in WIP.Search.md's "Index fields". // // Two more, `index` and `index_also`, are the hand-marked index entries: // terms an author names in a page's front matter or on a heading, for // jargon a reader looks up by a name the page's text may never use // (`conditional compilation` for the #If/#Const page). See -// `attachIndexMarks` below and WIP.Search.md's "What shipped, third round: -// the index pilot". +// `attachIndexMarks` below and WIP.Search.md's "Hand-marked index entries". import path from "node:path"; diff --git a/builder/vendor/just-the-docs/README.md b/builder/vendor/just-the-docs/README.md index c9d15612b..f37c57b4a 100644 --- a/builder/vendor/just-the-docs/README.md +++ b/builder/vendor/just-the-docs/README.md @@ -136,7 +136,7 @@ filter, so the replica returns what the site does. **Two extra fields joined in from the symbol index.** `initSearch()` adds `this.field('names', { boost: 100 })` and `this.field('qualified', { boost: -500 })` (50 until the fourth round, below), and passes `docs[i].names || ''` / `docs[i].qualified || ''` in the +500 })` (see the qualified-name change, below), and passes `docs[i].names || ''` / `docs[i].qualified || ''` in the matching `this.add({...})`. The values come from `builder/search.mjs`'s `joinSymbolsToEntries`, which attaches bare symbol names (`names`) and their `Container.Name` forms (`qualified`) to each `search-data.json` entry at @@ -238,7 +238,7 @@ section that only mentioned it.** Four changes, measured in property` found only pages mentioning both words, and never the `MaxHeight` section. Kept required otherwise, since `Mid function` needs the word to keep the `Mid =` statement out. See - [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: kind words". + [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Kind words". `exact` and `page` are derived in the browser; `primary` is the one field `search-data.json` gained (23 KB raw, 4 KB gzipped). @@ -251,8 +251,8 @@ section that only mentioned it.** Four changes, measured in terms as index entries, in a page's frontmatter or on a heading (see `docs/Documentation/Authoring.md`, "Index entries for the site search"), and `search-data.json` carries them as `index` and `index_also` lists on the -entry. [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "What shipped, -third round: the index pilot": +entry. [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Hand-marked +index entries": - *One field, both levels.* `initSearch()` adds a field `index`, boost 1000, that `indexField()` fills: each main term as `indexTermKey()` writes it @@ -283,8 +283,8 @@ member of that name, first.** `FileListBox.Name` ranked the FileListBox page first, and `Slider.KeyDown`, documented under the heading `KeyDown, KeyPress, KeyUp`, ranked 20th. `qualified` held the right token, but at boost 50 it counted for less than a title naming the container. See -[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "What shipped, fourth -round": +[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Qualified names and +stem twins": - *Boost 500.* `qualified` now weighs more than any text field, since naming a member with its container is the most specific thing a reader @@ -311,7 +311,7 @@ query sits outside `initSearch()`. `Printer.Fonts` both stem to `printer.font`, so every clause scored them alike and `Printer.Fonts` came second; so did `Collection.Item`, `Global.Printers`, `OLE.Update`, `Report.Page` and both `GetHeaders`. See -[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: stem twins": +[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Qualified names and stem twins": - *The twins are held whole.* `stemTwins(docs)` finds the qualified names whose stem another name shares (104, mostly a function and its `$` @@ -342,8 +342,7 @@ start or stop it; this branch's terms started it. `separateTokenSetKeys()` replaces `TokenSet#toString()` with one that ends each id with `,`, installed once from `initSearch()` beside the tokenizer wrapper. It is a fix to lunr, not to this theme: drop it if a lunr upgrade fixes the key. -See [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: lunr -invented words". +See [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "lunr patches". `offline.mjs`'s `JTD_INITSEARCH_FN_REPLACEMENT` calls it too. @@ -360,8 +359,8 @@ lunr's own `length`, which `intersect()` uses to pick the set it walks. Every page and section title as a query ranks exactly as before, score for score. Installed once from `initSearch()`, and from `offline.mjs`'s copy. It is a fix to lunr, not to this theme: drop it if a lunr upgrade fixes -`union`. See [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: -slow multi-word queries". +`union`. See [`../../../WIP.Search.md`](../../../WIP.Search.md)'s "lunr +patches". **Entities were indexed as written.** The search data keeps the page's HTML entities, and must: the results panel inserts titles and content @@ -374,8 +373,7 @@ the tokenizer splits it (`&`, `<`, `>`, `"`, `'`, position in the escaped text. The dot-run-split wrapper calls it, in `initSearch()` and in `offline.mjs`'s copy. A decoded separator (`-`, a hyphen) doesn't split its token. See -[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: entities in -the index". +[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "lunr patches". **A page's own title, typed whole, came second.** `Return Syntax` found the `Return` statement first, and `DTPicker Properties` the DTPicker @@ -388,8 +386,7 @@ query (as `indexTermKey()` writes both) scores three times as much, and the results are sorted again. It adds no field and no index term; each entry's two keys are computed on its first appearance in a result and kept in `searchLoaded()`'s `titleKeys`. See -[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Fixed: whole -titles". It sits outside `initSearch()`, so `offline.mjs` needs no change. +[`../../../WIP.Search.md`](../../../WIP.Search.md)'s "Whole titles". It sits outside `initSearch()`, so `offline.mjs` needs no change. **The index was fetched and built synchronously on every page load, even for readers who never opened search.** About 1.3s and 240MB of heap on a diff --git a/builder/vendor/just-the-docs/assets/js/just-the-docs.js b/builder/vendor/just-the-docs/assets/js/just-the-docs.js index d2b1e3e7f..0c321f381 100644 --- a/builder/vendor/just-the-docs/assets/js/just-the-docs.js +++ b/builder/vendor/just-the-docs/assets/js/just-the-docs.js @@ -157,7 +157,7 @@ function initSearch() { // WIP.Search.md's "Design" §2. `qualified` weighs most of the two // since only a qualified name reaches it (see doSearch()), and // naming one is the most specific thing a reader can type - // (WIP.Search.md, "What shipped, fourth round"). + // (WIP.Search.md, "Qualified names and stem twins"). this.field('names', { boost: 100 }); this.field('qualified', { boost: 500 }); // Patched: `exact` holds each bare name whole (see exactName() @@ -172,8 +172,7 @@ function initSearch() { this.field('page', { boost: 5 }); // Patched: `index` holds the entry's hand-marked index terms // (builder/search.mjs's attachIndexMarks), as indexField() below - // writes them. See WIP.Search.md, "What shipped, third round: - // the index pilot". + // writes them. See WIP.Search.md, "Hand-marked index entries". this.field('index', { boost: 1000 }); this.field('relUrl'); this.metadataWhitelist = ['position'] @@ -247,7 +246,7 @@ function exactName(name) { // each token here, after the split: the token keeps its position in the // escaped text, so highlighting still lines up. A decoded character that // would separate words (`-`, a hyphen) doesn't split its token. See -// WIP.Search.md, "Fixed: entities in the index". +// WIP.Search.md, "lunr patches". var NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; function decodeTokenEntities(token) { @@ -270,8 +269,7 @@ function decodeTokenEntities(token) { // trailing-wildcard query that reaches an invented word throws inside lunr: // any query with the word `a` did. A `,` after each id keeps the keys apart. // Installed once, since lunr is a global singleton; called from initSearch() -// above and from offline.mjs's copy of it. See WIP.Search.md, "Fixed: lunr -// invented words". +// above and from offline.mjs's copy of it. See WIP.Search.md, "lunr patches". function separateTokenSetKeys() { if (lunr.TokenSet.prototype.toString.separated) return; var separated = function() { @@ -298,8 +296,7 @@ function separateTokenSetKeys() { // The length is lunr's own (the first set's elements plus the second's, an // element in both counting twice), since intersect() walks the shorter set. // Installed once, since lunr is a global singleton; called from initSearch() -// above and from offline.mjs's copy of it. See WIP.Search.md, "Fixed: slow -// multi-word queries". +// above and from offline.mjs's copy of it. See WIP.Search.md, "lunr patches". function accumulateSetUnions() { var union = lunr.Set.prototype.union; if (union.accumulates) return; @@ -556,7 +553,7 @@ function buildIndexInSlices(config, docs, entry, onSuccess, onError) { // computed on its first appearance in a result. At five times, `_App // Comments` lifted the App page's Comments section, since the trimmer drops // the `_`; three leaves a margin. Called at the end of doSearch(). See -// WIP.Search.md, "Fixed: whole titles". +// WIP.Search.md, "Whole titles". var WHOLE_TITLE_BOOST = 3; function boostWholeTitles(results, docs, baseTokens, keys) { @@ -768,7 +765,7 @@ function searchLoaded(loadIndex) { // adjacent words, joined with a dot, name a member as a qualified name // does (`FileListBox Name`). Both also match a whole name, for the names // `qualified` holds whole (see qualifiedField()). See WIP.Search.md, - // "What shipped, fourth round" and "Fixed: stem twins". + // "Qualified names and stem twins". var plainTokens = queryTokens.filter(function(token) { return qualifiedTokens.indexOf(token) === -1; }); @@ -788,7 +785,7 @@ function searchLoaded(loadIndex) { // handling", `error` on its own isn't what the reader named, but in // "With statement", `With` is. Only in the singular: "Delegate Types" // and "New Functions" name a topic, not the one thing `Delegate` or - // `New` (WIP.Search.md, "Fixed: whole titles"). + // `New` (WIP.Search.md, "Query construction"). var textFields = ['title', 'content', 'names', 'qualified', 'page', 'relUrl']; var plainFields = ['title', 'content', 'names', 'page', 'relUrl']; var words = input.split(/\s+/).filter(Boolean); @@ -873,7 +870,7 @@ function searchLoaded(loadIndex) { // section rarely says "property" or "event", so requiring the word // dropped the very entry the reader named; then the pass runs again with // the kind words optional, and they still score. See WIP.Search.md, - // "Fixed: kind words". + // "Kind words". var results = []; if (baseTokens.length >= 2) { results = allWords(false); diff --git a/eval/search-experiments/README.md b/eval/search-experiments/README.md index 534e4fe60..d838dd9e1 100644 --- a/eval/search-experiments/README.md +++ b/eval/search-experiments/README.md @@ -68,8 +68,8 @@ whatever `EXP` knobs `eval/site_search.mjs` carries at the time: (`Printer Fonts`), and the top three for a list of probes. - `kinds.mjs `: every symbol written as its name and its kind (`MaxHeight property`, `Continue statement`), for the kinds the client - counts as kind words, with hit@1 by kind. See `WIP.Search.md`'s "Fixed: - kind words". + counts as kind words, with hit@1 by kind. See `WIP.Search.md`'s "Kind + words". - `sets.mjs `: every page's own multi-word title, typed as is (any entry of that page counts), and `
` for the one-word section titles 20 or more pages share (`Form Events`; @@ -79,4 +79,4 @@ whatever `EXP` knobs `eval/site_search.mjs` carries at the time: `$OUT`. - `knobs.patch`: the knobs the probe round measured (`plural`, `page=N`, `title=F`), as a diff against `eval/site_search.mjs`; apply it to - reproduce the table in `WIP.Search.md`'s "Probes: whole titles". + reproduce the measurements in `WIP.Search.md`'s "Whole titles". diff --git a/eval/search_quality.mjs b/eval/search_quality.mjs index a7bb40e46..ab6d0bdc2 100644 --- a/eval/search_quality.mjs +++ b/eval/search_quality.mjs @@ -52,17 +52,17 @@ // statement`), less `sub` and `member`, which readers don't say (a Sub // is a method to them). Any symbol of that name and kind counts. // Operators, with no word character, are left out, as the bare-name -// set measures them (WIP.Search.md, "Fixed: kind words"). +// set measures them (WIP.Search.md, "Ground truth sets"). // // For all three, where a symbol's URL is a page, not a section of one, any // section of that page counts as well: `DefInt` is documented by the // Deftype page, and its section headed `DefBool, DefByte, DefInt, ...` -// lands the reader on the same definition (WIP.Search.md, "Same-page -// sections count"). +// lands the reader on the same definition (WIP.Search.md, "Ground truth +// sets"). // // Two more sets are derived from the build's search entries, and judge // multi-word queries, which the one-word symbol queries never type -// (WIP.Search.md, "Probes: whole titles"): +// (WIP.Search.md, "Ground truth sets"): // // - Page titles: every page's own title of two or more words, typed as // the reader sees it (`Return Syntax`, `Delegate Types`). Any entry of diff --git a/test/search.test.mjs b/test/search.test.mjs index b7a8b2f0a..e33d39206 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -678,7 +678,7 @@ describe("reader-intent guard: online client, offline client, eval replica", () assert.match(src, /presence:\s*lunr\.Query\.presence\.REQUIRED/, `${label} doesn't require every word first`); assert.match(src, /usePipeline:\s*false/, `${label}'s required stems would be stemmed again`); // A plural kind word names a topic ("Delegate Types"), so it counts - // as a named word (WIP.Search.md, "Fixed: whole titles"). + // as a named word (WIP.Search.md, "Query construction"). assert.match( src, /KIND_WORDS\.(includes|indexOf)\(w\.toLowerCase\(\)\)/, @@ -688,7 +688,7 @@ describe("reader-intent guard: online client, offline client, eval replica", () }); }); -// Qualified names (WIP.Search.md, "What shipped, fourth round"): only a +// Qualified names (WIP.Search.md, "Qualified names and stem twins"): only a // qualified name reaches `qualified`, typed with a dot or as two adjacent // words, and there it outweighs a title naming its container. describe("qualified-name guard: online client, eval replica", () => { @@ -800,7 +800,7 @@ describe("qualified-name guard: online client, eval replica", () => { assert.ok(urls("_HiddenModule Input").includes("/Input")); }); - // Stem twins (WIP.Search.md, "Fixed: stem twins"): `Printer.Font` and + // Stem twins (WIP.Search.md, "Qualified names and stem twins"): `Printer.Font` and // `Printer.Fonts` stem alike, so `qualified` also holds each whole. test("all three fill `qualified` via qualifiedField(), and both queries match a whole name there", () => { for (const [label, src] of [ @@ -879,8 +879,7 @@ describe("qualified-name guard: online client, eval replica", () => { }); }); -// Hand-marked index entries (WIP.Search.md, "What shipped, third round: the -// index pilot"): the front matter's `index` / `index_also`, and the same on +// Hand-marked index entries (WIP.Search.md, "Hand-marked index entries"): the front matter's `index` / `index_also`, and the same on // a heading, which render.mjs's searchIndexMarksPlugin lifts off it into // page.searchIndexMarks, keyed by the heading's id. describe("index marks: from the page to its entries", () => { @@ -1185,8 +1184,8 @@ describe("index-term guard: online client, offline client, eval replica", () => // lunr 2.3.9 keys token-set nodes for minimisation by TokenSet#toString(), // which runs each edge's label into its child's id, so two different nodes // can share a key and be merged: the index's token set then holds invented -// words, and a wildcard query reaching one throws (WIP.Search.md, "Fixed: -// lunr invented words"). All three copies install separated keys. +// words, and a wildcard query reaching one throws (WIP.Search.md, "lunr +// patches"). All three copies install separated keys. describe("token-set key guard: online client, offline client, eval replica", () => { const read = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), "utf8"); const lunrPath = path.join(REPO_ROOT, "builder/vendor/just-the-docs/assets/js/vendor/lunr.min.js"); @@ -1244,7 +1243,7 @@ describe("token-set key guard: online client, offline client, eval replica", () }); }); -// Whole titles (WIP.Search.md, "Fixed: whole titles"): a query of two or more +// Whole titles (WIP.Search.md, "Whole titles"): a query of two or more // words that reads the same as a result's title, or its page title plus // title, scores three times as much, after lunr ranks. describe("whole-title guard: online client, eval replica", () => { @@ -1336,7 +1335,7 @@ describe("whole-title guard: online client, eval replica", () => { }); }); -// Entities (WIP.Search.md, "Fixed: entities in the index"): the search data +// Entities (WIP.Search.md, "lunr patches"): the search data // keeps the page's HTML entities, which the results panel needs, so each // copy's tokenizer wrapper decodes them per token, after the split. describe("entity guard: online client, offline client, eval replica", () => { @@ -1413,8 +1412,8 @@ describe("entity guard: online client, offline client, eval replica", () => { // lunr 2.3.9's Index#query gathers a REQUIRED clause's entries as a running // total of set unions, and each union copied both sets, so a short wildcard -// word made a query quadratic (WIP.Search.md, "Fixed: slow multi-word -// queries"). All three copies install a union that adds in place. +// word made a query quadratic (WIP.Search.md, "lunr +// patches"). All three copies install a union that adds in place. describe("set-union guard: online client, offline client, eval replica", () => { const read = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), "utf8"); const lunrPath = path.join(REPO_ROOT, "builder/vendor/just-the-docs/assets/js/vendor/lunr.min.js"); @@ -1526,7 +1525,7 @@ describe("set-union guard: online client, offline client, eval replica", () => { }); }); -// Kind words (WIP.Search.md, "Fixed: kind words"): a query naming one thing +// Kind words (WIP.Search.md, "Kind words"): a query naming one thing // and its kind requires the kind word only while some entry found has that // name in its title or as its exact name; otherwise the all-words pass runs // again with the kind words optional. From 1bbeda30960419a64f49523623ca7078e2dec5f0 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 16:28:27 +0200 Subject: [PATCH 15/33] packages lane waits only for requests sent after References opens: BETA 983 leaves one from start-up --- test/ide/packages.test.mjs | 64 ++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/test/ide/packages.test.mjs b/test/ide/packages.test.mjs index 15db42891..056a48ebd 100644 --- a/test/ide/packages.test.mjs +++ b/test/ide/packages.test.mjs @@ -12,9 +12,9 @@ // that nobody sees. OpenPackage puts the real hostAppObject back as it answers, // and every other read goes to the real HostReadFileDataBase64. // -// Each test states what BETA 995 does, which is what BETA 983 did by hand. In -// a lane, BETA 983's References page never finishes loading, so this file -// cannot run against it. If a test fails after an IDE update, +// Each test states what BETA 995 and 997 do; BETA 983 differs only in the +// last, where twinbasic/twinbasic#2442 puts its compiler in a crash loop and +// the IDE in Safe Mode. If a test fails after an IDE update, // the IDE has changed: update the matching entry in BUGS-TO-REPORT.md or bugs/filed/ and the // two Packages pages under docs/Features/Packages, and then this file. // @@ -42,7 +42,40 @@ const REFERENCES_JS = `${CONFIG_JS}.configData["project.references"].map((r) => // Every row of the References page, as text, to tell when the lists stop changing. const ALL_ROWS_JS = `[...document.querySelectorAll(".referencesListRow")].map((r) => r.parentElement.className + " " + r.textContent).join("\\n")`; // The compiler's requests still waiting for an answer. -const WAITING_JS = `Object.keys(debugSocket.requestsAwaitingResponse).length`; +// The sequence numbers of the compiler socket's requests still awaiting an +// answer. Loading the References page is waited for by the requests sent after +// it was opened: a request older than that may never be answered (BETA 983 +// leaves one from the IDE's start), and the page loads regardless. +const WAITING_JS = `JSON.stringify(Object.keys(debugSocket.requestsAwaitingResponse))`; +// The IDE keeps only the callback of a request awaiting its answer, so the +// command of each request is recorded by sequence number as it is sent: a page +// that never finishes loading is then reported by the requests left unanswered. +const TRACK_JS = `(() => { + if (!debugSocket.__names) { + const names = (debugSocket.__names = {}); + const request = debugSocket.request; + debugSocket.request = function (name) { + names[this.sequenceId] = name; + return request.apply(this, arguments); + }; + } + return true; +})()`; +const NAMES_JS = `JSON.stringify(debugSocket.__names || {})`; + +// Open the Settings editor on the References page, and wait for it to load. +async function openReferences(c) { + await c.evaluate(TRACK_JS); + const older = JSON.parse(await c.evaluate(WAITING_JS)); + const newer = async () => JSON.parse(await c.evaluate(WAITING_JS)).filter((k) => !older.includes(k)); + await c.evaluate(`executeIdeCommand("tbProject_ShowReferences"), null`); + assert.ok(await waitFor(c, (c) => c.evaluate(`!!${CONFIG_JS}`), { timeout: 20 * 1000 }), "no Settings editor"); + if (!(await waitFor(c, async () => (await newer()).length === 0, { timeout: 60 * 1000 }))) { + const names = JSON.parse(await c.evaluate(NAMES_JS)); + const unanswered = (await newer()).map((k) => names[k] ?? `request ${k}`); + assert.fail(`the References page never finished loading: unanswered ${unanswered.join(", ")}`); + } +} // The picker and the file read, answered for one file; and the compiler's // answer to the import, recorded. @@ -80,12 +113,7 @@ scenario("Import from file... in the Packages dialog", (lane) => { }); test("the imported package is listed, but not ticked", async () => { - await c.evaluate(`executeIdeCommand("tbProject_ShowReferences"), null`); - assert.ok(await waitFor(c, (c) => c.evaluate(`!!${CONFIG_JS}`), { timeout: 20 * 1000 }), "no Settings editor"); - assert.ok( - await waitFor(c, async () => (await c.evaluate(WAITING_JS)) === 0, { timeout: 60 * 1000 }), - "the References page never finished loading", - ); + await openReferences(c); await click(c, { css: "div", text: "Available Packages" }); await c.evaluate(stubsJs(pack, readFileSync(pack).toString("base64"))); await click(c, { css: "div", text: "Import from file..." }); @@ -215,16 +243,6 @@ scenario("Replacing an embedded package", (lane) => { writeFileSync(bare, withoutPackagesFolder(readFileSync(v1))); }); - // Open the Settings editor on the References page, and wait for it to load. - async function openReferences() { - await c.evaluate(`executeIdeCommand("tbProject_ShowReferences"), null`); - assert.ok(await waitFor(c, (c) => c.evaluate(`!!${CONFIG_JS}`), { timeout: 20 * 1000 }), "no Settings editor"); - assert.ok( - await waitFor(c, async () => (await c.evaluate(WAITING_JS)) === 0, { timeout: 60 * 1000 }), - "the References page never finished loading", - ); - } - // Click the tick box of the row in `list` that names a package and is in // the state `ticked`, with a real click, as a person does. Available // Packages also lists TWINSERV's packages, which arrive by a request the @@ -335,7 +353,7 @@ scenario("Replacing an embedded package", (lane) => { const host = path.join(lane.work, name); cpSync(HOST, host, { recursive: true }); c = await lane.open(host, { folder: path.join(lane.work, `${name}-staged`) }); - await openReferences(); + await openReferences(c); await importFile(file); await tick(DESCRIPTION, { ticked: false, list: AVAILABLE }); const applied = await apply(); @@ -358,11 +376,11 @@ scenario("Replacing an embedded package", (lane) => { applies.embed = applied; // Step 2: untick v1, import v2, tick v2. - await openReferences(); + await openReferences(c); await tick("DocProbePkg", { ticked: true, list: INSTALLED }); if (applyAfterUntick) { applies.untick = await apply({ errors: true }); - await openReferences(); + await openReferences(c); } await importFile(v2); await tick(DESCRIPTION, { ticked: false, list: AVAILABLE }); From 7257cfd8a52e5dfbbe2fc0fb7c81d5c4d5eef8cc Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 6 Oct 2026 17:07:52 +0200 Subject: [PATCH 16/33] Exe runs close and report boxes and read the event log: tbrun --exe exits 6 on a box or fault (an unhandled error showed nothing and timed out, or exited 0), every vb6run exe goes through the launcher so a fault is recorded, VBRuntime's logged errors read (lib/win-fault.mjs) --- WIP.Harness.md | 15 ++- WIP.md | 2 +- docs/Documentation/Tools.md | 20 ++-- scripts/bug_repro.mjs | 12 +- scripts/lib/vb6.mjs | 224 ++++++++++++++++++------------------ scripts/lib/win-fault.mjs | 69 +++++++++++ scripts/tbrun.mjs | 57 +++++++-- scripts/vb6run.mjs | 16 +-- 8 files changed, 271 insertions(+), 144 deletions(-) create mode 100644 scripts/lib/win-fault.mjs diff --git a/WIP.Harness.md b/WIP.Harness.md index ff4f457f0..7040018d8 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -603,8 +603,9 @@ Rules it follows: form loaded returns too: the IDE then prints `[DEBUGGER] Waiting for remaining forms to close...` after the sentinel, which `sentinelIndex` allows and `tbrun` prints (BETA 983 and 995). `check_twin_parsers` has fixtures for both. -- **`tbrun --exe` exits 6 when the exe exited with a code other than 0**, or was still - running at `--timeout` and was ended. A run that would exit 5 exits 5 first, since the +- **`tbrun --exe` exits 6 when the exe exited with a code other than 0**, when the event + log records that it faulted, when it opened a box, or when it was still running at + `--timeout` and was ended. A run that would exit 5 exits 5 first, since the IDE's run is the one `--exe` follows. A reader of the console that is not `tbrun` should **compare the whole console before and @@ -667,6 +668,16 @@ the exit code from `ExitProcess 7` comes back as 7, a hung exe is ended at `--ti with nothing left running, and **the exe never evaluates `Debug.Assert`, with or without LLVM**, as VB6 drops `Debug` statements from a compiled program. +**An unhandled error in the exe opens a box and waits for it**, so `--exe` passes +`dialogs: "close"`: the launcher records each box and presses OK. Without that the exe +sat until `--timeout`. The box is the only sign of the error. In BETA 997 a plain exe +shows *shutdown*, `Run-time error '5' `, and exits with code 0 once the box +is closed; an LLVM-compiled one shows *_TB_ERROR_HANDLER*, `unhandled error in +Probe.Main`, with no number or description, and then dies of an access violation at +address 0 (`0xC0000005`). `tbrun` also reads the Application log for a fault the exe +ended on (`lib/win-fault.mjs`), since an exit code need not say it; the record is written +because the launcher, not Node, starts the exe. + **`TbRun.Out` knows it is in the IDE because the wrapper says so.** The wrapper sets `TbRun.tbrun_InIDE` before it calls the probe; the exe runs `Sub Main`, never the wrapper. `App.IsInIDE` would say the same, but `App` exists only in a project that references the diff --git a/WIP.md b/WIP.md index 4c9f51cec..09b7fbe22 100644 --- a/WIP.md +++ b/WIP.md @@ -121,7 +121,7 @@ node scripts/tbrun.mjs # what does it print - **It runs the IDE on a private Windows desktop**, so it cannot seize focus. Set `TBBUILD_SHOW=1` while working interactively and leave it unset for unattended runs: a wedged IDE nobody can see is expensive to diagnose. - **One project per IDE**, 6--8 seconds each and flat in project size. Reusing a live IDE for a second project wedges it, so the cold start is the unit of work, not overhead to optimise away. Run concurrently to go faster: distinct `--port` values give distinct DevTools ports, user-data folders, temp folders (`%TEMP%\tbbuild-tmp-`; IDEs building in one temp folder fail now and then to write the type library) and desktops. - **Keep a probe that might crash the compiler in a project of its own.** twinBASIC runs the compiler in the same process as user code, so one bad probe can take the run down and cost the other probes their answer. -- **`tbrun` takes an exported tree, not a `.twinproj`**, because it has to pin `project.buildPath` in its own staged copy. A project still on the default template opens a native Save dialog that is invisible on the private desktop: the build never happens, while every health check says the IDE is fine. The probe is a module with a `[RunAfterBuild]` Sub, and must start with `Debug.Cls`. Its exit codes: 0 the probe ran and its output was captured, 1 the project has compile errors, 2 the harness failed or the build did after a clean compile, or the probe's module holds a procedure named like the module, which stops the `[RunAfterBuild]` Sub from running (BETA 997; `--allow-name-clash` runs it anyway), 3 no output, 4 the compiler crashed, as `tbbuild` reports it, 5 the probe ended before it returned (`End`, or an error raised with no handler in LLVM-compiled code, which ends the run silently), 6 with `--exe`, the exe exited with a code other than 0 or was still running at `--timeout`. +- **`tbrun` takes an exported tree, not a `.twinproj`**, because it has to pin `project.buildPath` in its own staged copy. A project still on the default template opens a native Save dialog that is invisible on the private desktop: the build never happens, while every health check says the IDE is fine. The probe is a module with a `[RunAfterBuild]` Sub, and must start with `Debug.Cls`. Its exit codes: 0 the probe ran and its output was captured, 1 the project has compile errors, 2 the harness failed or the build did after a clean compile, or the probe's module holds a procedure named like the module, which stops the `[RunAfterBuild]` Sub from running (BETA 997; `--allow-name-clash` runs it anyway), 3 no output, 4 the compiler crashed, as `tbbuild` reports it, 5 the probe ended before it returned (`End`, or an error raised with no handler in LLVM-compiled code, which ends the run silently), 6 with `--exe`, the exe exited with a code other than 0, the event log records that it faulted, it opened a box (an unhandled error opens one, and `tbrun` closes it), or it was still running at `--timeout`. - **Measuring LLVM: `tbrun --llvm`** (or `--compiler-options ""`) compiles the whole probe with LLVM, by setting `compiler.debugOptions` --- what a `[RunAfterBuild]` run is compiled with; `compiler.buildOptions` alone does not reach it (BETA 995) --- and `compiler.buildOptions`, for the exe. It refuses a Community or Personal licence. `--exe` also runs the built exe on a private desktop; the exe runs `Sub Main` and prints with `TbRun.Out`, a module `tbrun` adds, because `Debug.Print` writes nothing in an exe. - **A census is evidence, not applicability.** The corpus not using an attribute somewhere does not mean the compiler refuses it there, and the reverse also holds. Only a probe settles that. - **The sweep is the probe for a whole attribute.** Before writing or changing an attribute's `Applicable to:` line, run `sweep_attributes.mjs --names ` (a minute or a few; the once-per-project ones, `RunAfterBuild` and `RunBeforeStartupObject`, take several); it tries every declaration site, not only the ones already claimed. **Always pass `--out` and `--dump-results`**: a run piped through `tail` keeps one line of a report that takes ten minutes. A clean build there means the compiler accepts the attribute, not that it does anything, and an Enum member cannot be tested at all (see [WIP.Harness.md](WIP.Harness.md#sweeping-every-attribute-at-every-site)). diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index f1ee49472..813d7cb50 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -991,12 +991,12 @@ test the default compiler. **`--exe` also runs the exe the build wrote**, after the probe has run in the IDE. It starts the exe on a private desktop, as it starts the IDE, so a message box the exe opens -appears on no desktop you use. It also ends the exe at `--timeout`. The exe runs its +appears on no desktop you use, and it closes every box the exe opens by pressing OK, printing the box's title and text: an unhandled error in an exe opens a box and waits. It also ends the exe at `--timeout`. The exe runs its `Sub Main`, not the `[RunAfterBuild]` Sub, so a probe for both gives the tree a `Main` that calls the probe, and leaves out the template's own module with an empty `Main`. A built exe writes nothing with `Debug.Print`, so the probe prints with `TbRun.Out`, from a module `tbrun` adds to the staged copy. `TbRun.Out` writes to the Debug Console in the IDE, and to a file -`tbrun` reads in the exe. The exe's lines and its exit code follow the probe's output. +`tbrun` reads in the exe. The exe's exit code, the fault the Windows Application log records for it, if any, the boxes it opened, and its lines follow the probe's output. | Flag | Effect | |---|---| @@ -1006,7 +1006,7 @@ adds to the staged copy. `TbRun.Out` writes to the Debug Console in the IDE, and | `--quiet ` | How long the console must stop changing before the output counts as complete, when the probe has not returned. Default 2500. Raise it well above the default for a probe that drives an out-of-process server, which can take longer than that to start. | | `--llvm` | Compile the whole probe, and the exe, with LLVM. The same as `--compiler-options +llvm`. | | `--compiler-options ` | The project's compiler options, for the run and the exe. | -| `--exe` | Also run the built exe, and print what it writes with `TbRun.Out` and its exit code. | +| `--exe` | Also run the built exe, and print what it writes with `TbRun.Out`, its exit code, the fault the event log records for it, and the boxes it opened, each closed with OK. | | `--allow-name-clash` | Run a probe whose module holds a procedure named like the module, which is refused otherwise. twinBASIC does not run its `[RunAfterBuild]` Sub, so the run exits 5. | | `--raw` | Keep the console's timestamp column, which is otherwise stripped. | | `--json` | One object with the path of the built file, the target, the captured lines, whether the probe returned, the licence an LLVM run checked, the exe's run, the IDE pid and anything reaped. | @@ -1044,7 +1044,7 @@ behind. That includes the target the IDE remembers for each project, which a `wi writes. **A probe builds for the target `--arch` names**, whatever the IDE remembers, so a kept IDE switched to `win64` does not make later runs on the same port build 64-bit. -Exit codes: **0** the probe ran and its output was captured; **1** the project has compile errors (the diagnostics are printed); **2** a refused command line (a source folder that is missing or has no `Settings` file included), no IDE, an IDE that did not start, a compile that never settled, a build that failed after a clean compile, a probe that never ran or stopped at a procedure that failed code generation, an LLVM run on a Community or Personal licence, an `--exe` run with no exe built, or a crash; **3** no output: the console held none before the timeout, or the probe printed none after its last `Debug.Cls`; **4** the compiler crashed, or restarted twice, while compiling the project; **5** the probe ended before it returned, its output printed all the same; **6** under `--exe`, the exe exited with a code other than 0, or was still running after `--timeout` and was ended, its output and exit code printed all the same. A run that would exit 5 exits 5 whatever the exe did. +Exit codes: **0** the probe ran and its output was captured; **1** the project has compile errors (the diagnostics are printed); **2** a refused command line (a source folder that is missing or has no `Settings` file included), no IDE, an IDE that did not start, a compile that never settled, a build that failed after a clean compile, a probe that never ran or stopped at a procedure that failed code generation, an LLVM run on a Community or Personal licence, an `--exe` run with no exe built, or a crash; **3** no output: the console held none before the timeout, or the probe printed none after its last `Debug.Cls`; **4** the compiler crashed, or restarted twice, while compiling the project; **5** the probe ended before it returned, its output printed all the same; **6** under `--exe`, the exe exited with a code other than 0, the Application event log records that it faulted, it opened a box (which `tbrun` closed), or it was still running after `--timeout` and was ended, its output, exit code, fault and boxes printed all the same. A run that would exit 5 exits 5 whatever the exe did. ### bug_repro.mjs {: #bug-repro } @@ -1074,7 +1074,7 @@ outside every gate and outside CI, and `verify` is run by a person only. | `compile ` | Packs, then compiles the project with `tbbuild --json` and prints its diagnostics. | | `build ` | Packs, then compiles and builds it with `tbbuild --build`, or `--llvm` when that is given. A build that fails prints the build log and the failing line. | | `run ` | Copies `src/` to `%TEMP%\bugrepro\\`, adds a `TbRunProbe` module whose `[RunAfterBuild]` Sub calls `Debug.Cls` and then `Main`, runs `tbrun` on the copy and prints what it captured. The Sub clears `WEBVIEW2_USER_DATA_FOLDER` and `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` while `Main` runs: the harness starts the IDE with both, and WebView2 lets them override what a WebView2 control in the project asks for, so the control would fail to start inside the IDE's process. Apart from the pictures below, the tree under `bugs/` is not changed. With `--exe` no probe module is added: `tbrun` runs `Sub Main` in the built exe. With `images` in `repro.json`, `run` also gives `tbrun` an empty folder in `BUGREPRO_IMAGES`, which the IDE and, with `--exe`, the exe inherit, and when the run finishes keeps each picture as `images/-tb.png`. A picture the probe did not write is a failure that names it, exit 9. | -| `vb6 ` | Builds the reproducer's VB6 project, in `vb6/`, and prints what its exe wrote. By convention `Probe.vbp` builds `Probe.exe`, which writes its findings to `out.txt` beside the exe and handles every error itself. The sources are copied to a new folder under the OS temp folder, so no exe or output lands in the repository, and the copy's project is given VB6's Unattended Execution option, which sends a message box or an unhandled error to the Windows event log instead of the desktop. VB6 refuses that option for a project with a form, a user control, a property page, a user document or a designer, so such a project is built without it. The exe always runs on a private desktop, inside a job that ends everything it starts, as `tbrun --exe` runs one: a box it shows is on no desktop anyone uses, and the time limit ends it. The project is refused, exit 2, when its sources call `MsgBox` or `InputBox`, which would open a modal box. VB6 is found from `--vb6 `, else the `VB6_EXE` environment variable, else `VB98\VB6.EXE` under `C:\Program Files (x86)\Microsoft Visual Studio` and then `C:\Program Files\Microsoft Visual Studio`; it is started from Node with an argument array and no shell, as [`vb6run.mjs`](#vb6run) does it. `--timeout` is the time limit on the exe (default 30 s), and `--keep` keeps the work folder and prints where it is. It needs no IDE. With `images` in `repro.json`, the exe is given an empty folder in `BUGREPRO_IMAGES`, and each picture it wrote is kept as `images/-vb6.png`. | +| `vb6 ` | Builds the reproducer's VB6 project, in `vb6/`, and prints what its exe wrote. By convention `Probe.vbp` builds `Probe.exe`, which writes its findings to `out.txt` beside the exe and handles every error itself. The sources are copied to a new folder under the OS temp folder, so no exe or output lands in the repository, and the copy's project is given VB6's Unattended Execution option, which sends a message box or an unhandled error to the Windows event log instead of the desktop. VB6 refuses that option for a project with a form, a user control, a property page, a user document or a designer, so such a project is built without it. The exe always runs on a private desktop, inside a job that ends everything it starts, as `tbrun --exe` runs one: a box it shows is on no desktop anyone uses, and is closed by pressing OK and reported, and the time limit ends the exe. An unhandled error in an exe built with Unattended Execution exits with code 0 and leaves no fault; the VB runtime writes it to the Application event log instead, and that record is reported. The project is refused, exit 2, when its sources call `MsgBox` or `InputBox`, which would open a modal box. VB6 is found from `--vb6 `, else the `VB6_EXE` environment variable, else `VB98\VB6.EXE` under `C:\Program Files (x86)\Microsoft Visual Studio` and then `C:\Program Files\Microsoft Visual Studio`; it is started from Node with an argument array and no shell, as [`vb6run.mjs`](#vb6run) does it. `--timeout` is the time limit on the exe (default 30 s), and `--keep` keeps the work folder and prints where it is. It needs no IDE. With `images` in `repro.json`, the exe is given an empty folder in `BUGREPRO_IMAGES`, and each picture it wrote is kept as `images/-vb6.png`. | | `verify [slug ...]` | Reads `repro.json` for each named reproducer, or every one under `bugs/` and `bugs/filed/`, runs what it says and reports one line each. A filed reproducer's line is labelled with its issue, such as `(filed #2453)`, and the summary counts the filed ones on a line of their own. | | `file ` | Moves the entry that names `` `.twinproj` `` out of `BUGS-TO-REPORT.md`, together with one `---` beside it, into `bugs/filed//REPORT.md`, whose first line links the issue (`--existing`: "Covered by the existing issue ..." when an existing issue already covered the bug) and which holds no mark line. Then moves `bugs//` to `bugs/filed//` and records `issue` (and `existing`) in its `repro.json`. An entry whose reproducer is not an attachment names `bugs//` in its closing comment instead. Refused, with exit 2 and nothing changed, when no entry or more than one names the slug, `bugs/` is missing, or `bugs/filed/` exists. | | `file --marked` | Does that for every entry with a mark line directly under its title: `*FILED #*`, `*CAPTURED IN EXISTING #*` or `*CAPTURED IN \#*`, the issue and the slug taken from the entry. A line such as `*DEFERRED until after v1*` is not a mark, and the entry is skipped. If a mark cannot be read, or a slug cannot be settled, the entries concerned are printed and nothing at all is filed, exit 2. One line is printed for each entry filed. Takes neither a slug nor an issue. | @@ -1126,7 +1126,7 @@ wrong type, is refused with exit 2, naming the file and the key, before anything | `probe` | `probe` mode. A script under `scripts/`, then its arguments: `verify` runs `node + + diff --git a/add-in/Resources/SHELL/shell.js b/add-in/Resources/SHELL/shell.js new file mode 100644 index 000000000..3d1acd184 --- /dev/null +++ b/add-in/Resources/SHELL/shell.js @@ -0,0 +1,82 @@ +// The page of the detached help window: the pane's elements, with the same ids, +// and the add-in at the other end of window.chrome.webview. A message either +// way is "verb:payload", split at the first colon. +// +// Page to add-in: ready, search:, enter, pick:, hover:<1|0>, +// attach, browser, frame: (the frame has loaded src). +// Add-in to page: theme: a line break and , search:, +// results:, page:, summary:, hover:<1|0>, hoverdisabled:<1|0>, +// browser:<1|0 enabled>, focus:. +(function () { + var host = window.chrome.webview; + var byId = function (id) { return document.getElementById(id); }; + var search = byId("helpSearch"); + var results = byId("helpResults"); + var summary = byId("helpSummary"); + var frame = byId("helpPage"); + var browser = byId("helpBrowser"); + var hover = byId("helpHover"); + + function post(text) { host.postMessage(text); } + + search.addEventListener("input", function () { post("search:" + search.value); }); + search.addEventListener("keyup", function (e) { + if (e.key !== "Enter") return; + post("search:" + search.value); + post("enter"); + }); + results.addEventListener("click", function (e) { + var row = e.target.closest(".hit"); + if (row) post("pick:" + row.getAttribute("data-pick")); + }); + hover.addEventListener("change", function () { post("hover:" + (hover.checked ? "1" : "0")); }); + browser.addEventListener("click", function () { post("browser"); }); + byId("helpAttach").addEventListener("click", function () { post("attach"); }); + frame.addEventListener("load", function () { + if (frame.getAttribute("src")) post("frame:" + frame.src); + }); + + // The IDE's theme: its group for the plain palette, and its properties. + function setTheme(payload) { + var line = payload.indexOf("\n"); + document.documentElement.setAttribute("data-group", line < 0 ? payload : payload.slice(0, line)); + byId("theme").textContent = line < 0 ? "" : payload.slice(line + 1); + } + + function showPage(url) { + frame.src = url; + frame.style.display = ""; + summary.style.display = "none"; + } + + function showSummary(html) { + summary.innerHTML = html; + summary.style.display = ""; + frame.style.display = "none"; + } + + function setResults(html) { + results.innerHTML = html; + results.style.display = html ? "" : "none"; + } + + host.addEventListener("message", function (e) { + var text = String(e.data); + var colon = text.indexOf(":"); + var verb = colon < 0 ? text : text.slice(0, colon); + var payload = colon < 0 ? "" : text.slice(colon + 1); + switch (verb) { + case "theme": setTheme(payload); break; + case "search": search.value = payload; break; + case "results": setResults(payload); break; + case "page": showPage(payload); break; + case "summary": showSummary(payload); break; + case "hover": hover.checked = payload === "1"; break; + case "hoverdisabled": hover.disabled = payload === "1"; break; + case "browser": browser.disabled = payload !== "1"; break; + case "focus": search.focus(); break; + } + }); + + post("ready"); +})(); diff --git a/add-in/Resources/STYLESHEETS/pane.css b/add-in/Resources/STYLESHEETS/pane.css index 6ebb89bdf..3f96dc53e 100644 --- a/add-in/Resources/STYLESHEETS/pane.css +++ b/add-in/Resources/STYLESHEETS/pane.css @@ -1,6 +1,8 @@ /* The help pane, inside its tool window's shadow root. The colours are the IDE theme's own custom properties, which the IDE sets on its document and - which a shadow root inherits, so a change of theme needs no code. */ + which a shadow root inherits, so a change of theme needs no code. The + detached window's page (SHELL/shell.html) has the same elements, with the + same ids, and this file inlined; the add-in gives it the same properties. */ /* --helpRow is the height of one result row. The list view draws its rows out of flow, so the add-in sets the list's height to its row count times @@ -32,7 +34,9 @@ } #helpSearch, -#helpBrowser { +#helpBrowser, +#helpDetach, +#helpAttach { font: inherit; color: inherit; background: transparent; @@ -45,7 +49,9 @@ min-width: 0; } -#helpBrowser { +#helpBrowser, +#helpDetach, +#helpAttach { flex: 0 0 auto; cursor: pointer; } @@ -56,6 +62,8 @@ } #helpBrowser:enabled:hover, +#helpDetach:hover, +#helpAttach:hover, #helpResults .hit:hover { background: var(--themeTreeItemHoverBackColor); } diff --git a/add-in/Settings b/add-in/Settings index 051d4a403..934e873a4 100644 --- a/add-in/Settings +++ b/add-in/Settings @@ -3,7 +3,7 @@ "project.appTitle": "tbDocsHelp", "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", "project.buildType": "Standard DLL", - "project.description": "The twinBASIC documentation help add-in: F1 shows the page for the name under the cursor in a help pane, which can also search the index; its Hover help box adds links to a name's pages to the IDE's hover. WIP.HelpAddin.md is its plan. The symbol index is embedded as Resources/SYMBOLS/symbols.json, a committed copy of a docs build's tB/symbols.json. With Resources/HELP/site.zip, which a local build of the documentation writes and git ignores, the pane's pages come from the add-in's own server instead of the live site.", + "project.description": "The twinBASIC documentation help add-in: F1 shows the page for the name under the cursor in a help pane, which can also search the index; its Hover help box adds links to a name's pages to the IDE's hover, and its Detach button moves the pane into a window of the add-in's own, a Form holding a WebView2 control. WIP.HelpAddin.md is its plan. The symbol index is embedded as Resources/SYMBOLS/symbols.json, a committed copy of a docs build's tB/symbols.json. With Resources/HELP/site.zip, which a local build of the documentation writes and git ignores, the pane's pages come from the add-in's own server instead of the live site.", "project.exportPathIsV2": true, "project.id": "{4581D804-116B-436E-B7AB-1E4417F9703C}", "project.name": "tbDocsHelp", @@ -31,6 +31,37 @@ "versionMajor": 0, "versionMinor": 0, "versionRevision": 1 + }, + { + "id": "{5D19653F-F49E-41F2-ABF8-16E473A89A7C}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WebView2 Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WebView2Package", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 3 + }, + { + "hasBeenSplit": true, + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WinNativeForms Package", + "path32": "/Packages/WindowsControlsPackage", + "path64": "/Packages/WindowsControlsPackage", + "publishedDate": "09-FEB-2022", + "publishedTime": "15:48:54", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WindowsControlsPackage", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 } ], "project.settingsVersion": 1, diff --git a/add-in/Sources/HelpAddIn.twin b/add-in/Sources/HelpAddIn.twin index 730320b07..eeb30e969 100644 --- a/add-in/Sources/HelpAddIn.twin +++ b/add-in/Sources/HelpAddIn.twin @@ -7,7 +7,11 @@ ' The pages come from the add-in's own server when it was built with the help ' archive (HelpServer), and from the live site otherwise. The pane's Hover ' help box turns on links to the pages in the IDE's hover under the mouse -' (HoverHelp). +' (HoverHelp). The pane's Detach button moves the whole pane into a window of +' the add-in's own (HelpWindow), which can go to another monitor; Attach, or +' closing the window, brings it back. While it is detached everything that +' would show in the pane shows in the window, and the pane's frame is not +' loaded. The pane keeps the state either way: the window is another view of it. ' Every line it prints to the DEBUG CONSOLE starts "[tbDocsHelp] ", except ' "open " (see OpenUrl). Private Class HelpAddIn @@ -32,6 +36,14 @@ Private Class HelpAddIn Private Server As HelpServer ' Nothing unless the add-in has the archive Private WithEvents Hovering As HoverHelp ' Nothing without an index Private HoverBox As HtmlElement ' the pane's Hover help checkbox + Private DetachButton As HtmlElement + Private WithEvents HelpWin As HelpWindow ' Nothing until the first Detach, then kept + Private Detached As Boolean ' the window shows the help, the pane is hidden + Private LoadedFrame As String ' the src the pane's frame was given + Private SearchText As String ' what the search box holds, in either view + Private HoverOn As Boolean ' what the Hover help box holds, in either view + Private SummaryHtml As String ' what ShowSummary shows + Private ShowingSummary As Boolean ' the summary shows in place of the page Private Const BASE_URL As String = "https://docs.twinbasic.com" Private Const TAG As String = "[tbDocsHelp] " @@ -40,6 +52,8 @@ Private Class HelpAddIn ' SaveSetting's application name; test/addin/lanes.mjs names it too. Private Const SETTINGS_APP As String = "tbDocsHelp" Private Const SEARCH_LIMIT As Long = 100 + ' The window's settings: whether it is detached, and where it is, in pixels. + Private Const WINDOW_SECTION As String = "Window" Public Sub New(ByVal Host As Host) Set Me.Host = Host @@ -63,6 +77,9 @@ Private Class HelpAddIn ' pressing anything. Host.DebugConsole.PrintText TAG & "loaded, " & indexState & ", test switch " & _ IIf(TestSwitchOn(), "on", "off") + ' A compiler restart ends the window with the compiler: this instance + ' opens it again. + If GetSetting(SETTINGS_APP, WINDOW_SECTION, "Detached", "0") = "1" Then Detach End Sub Private Function LoadIndex() As String @@ -86,7 +103,10 @@ Private Class HelpAddIn If Len(Server.Origin) = 0 Then Set Server = Nothing End Sub + ' The window goes with the compiler, so its place is saved here as well as + ' when it is attached or closed. Private Sub Class_Terminate() + If Not HelpWin Is Nothing Then SaveWindowPlace If Not Server Is Nothing Then Server.CloseAll End Sub @@ -124,18 +144,35 @@ Private Class HelpAddIn Return PagesUrl() & Left$(url, hash - 1) & query & Mid$(url, hash) End Function - ' A theme change loads the page again with the new theme. The pane itself - ' follows the theme through the IDE's CSS variables (pane.css). + ' A theme change loads the page again with the new theme: in the pane's + ' frame, or in the window, which is also sent the theme's properties. The + ' pane itself follows the theme through the IDE's CSS variables (pane.css). Private Sub Host_OnChangedTheme(ByVal ThemeName As String) - If Len(PageUrl) > 0 And Not Frame Is Nothing Then Frame.Properties.src = FrameUrl(PageUrl) + On Error GoTo Failed + If Detached Then + HelpWin.Post "theme:" & ThemeMessage() + If Len(PageUrl) > 0 And Not ShowingSummary Then HelpWin.Post "page:" & FrameUrl(PageUrl) + ElseIf Len(PageUrl) > 0 And Not Frame Is Nothing Then + LoadedFrame = FrameUrl(PageUrl) + Frame.Properties.src = LoadedFrame + End If + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub + ' What the window is told of the theme: the group, for the plain palette it + ' falls back on, a line break, and the rule with the theme's properties. + Private Function ThemeMessage() As String + Return Host.Themes.ActiveThemeNameGroup & vbLf & ThemeRule(Host.Themes.ActiveThemeName) + End Function + ' The pane's elements. Showing a tool window sets its root's display to ' block, so the flex layout is on a wrapper inside it. Private Sub BuildPane() ' Kept apart from the box: a property read straight after it is set ' can still give the old value. - Dim hoverOn As Boolean = GetSetting(SETTINGS_APP, "Hover", "On", "0") = "1" + HoverOn = GetSetting(SETTINGS_APP, "Hover", "On", "0") = "1" Set Pane = Host.ToolWindows.Add("tbDocsHelp", PANE_ID) Pane.Title = "TWINBASIC HELP" Pane.ApplyCss StrConv(LoadResDataInternal("pane.css", "STYLESHEETS"), VbStrConv.vbFromUTF8) @@ -156,10 +193,13 @@ Private Class HelpAddIn Set BrowserButton = .ChildDomElements.Add("helpBrowser", "button") BrowserButton.Properties.innerText = "Open in browser" BrowserButton.AddEventListener("click", AddressOf BrowserClicked) + Set DetachButton = .ChildDomElements.Add("helpDetach", "button") + DetachButton.Properties.innerText = "Detach" + DetachButton.AddEventListener("click", AddressOf DetachClicked) With .ChildDomElements.Add("helpHoverLabel", "label") Set HoverBox = .ChildDomElements.Add("helpHover", "input") HoverBox.Properties.type = "checkbox" - HoverBox.Properties.checked = hoverOn + HoverBox.Properties.checked = HoverOn HoverBox.Properties.disabled = Hovering Is Nothing HoverBox.AddEventListener("change", AddressOf HoverToggled) .ChildDomElements.Add("helpHoverText", "span").Properties.innerText = "Hover help" @@ -180,26 +220,40 @@ Private Class HelpAddIn Dim mouseSink As HtmlElement = .ChildDomElements.Add("helpAskMouse", "div") mouseSink.Properties.style.display = "none" Hovering.Attach mouseSink - Hovering.Enabled = hoverOn + Hovering.Enabled = HoverOn End If End With End With Set ResultIds = New Collection LastSearch = "" - If Len(PageUrl) > 0 Then Frame.Properties.src = FrameUrl(PageUrl) + SearchText = "" + ShowingSummary = False + LoadedFrame = "" + If Len(PageUrl) > 0 And Not Detached Then + LoadedFrame = FrameUrl(PageUrl) + Frame.Properties.src = LoadedFrame + End If End Sub ' Hover help is off until the box is ticked, and stays as it was left. Private Sub HoverToggled(ByVal eventInfo As HtmlEventProperties) On Error GoTo Failed - Dim isOn As Boolean = HoverBox.Properties.checked - SaveSetting SETTINGS_APP, "Hover", "On", IIf(isOn, "1", "0") - Hovering.Enabled = isOn + SetHover HoverBox.Properties.checked Exit Sub Failed: Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub + ' Turns hover help on or off, from the pane's box or the window's, and + ' shows it in both. + Private Sub SetHover(ByVal isOn As Boolean) + HoverOn = isOn + SaveSetting SETTINGS_APP, "Hover", "On", IIf(isOn, "1", "0") + Hovering.Enabled = isOn + HoverBox.Properties.checked = isOn + If Detached Then HelpWin.Post "hover:" & IIf(isOn, "1", "0") + End Sub + Private Sub Hovering_Pick(ByVal url As String) ShowPage url End Sub @@ -217,8 +271,23 @@ Private Class HelpAddIn End Sub Private Sub ShowPaneForSearch() - Pane.Visible = True - SearchBox.Properties.focus() + ShowView + If Detached Then + HelpWin.Post "focus:" + Else + SearchBox.Properties.focus() + End If + End Sub + + ' Shows the view that has the help: the pane, or the detached window, which + ' is left where it is unless it is minimized. Then it is restored without + ' taking the focus from the editor the user is typing in. + Private Sub ShowView() + If Not Detached Then + Pane.Visible = True + ElseIf IsMinimized(HelpWin.hWnd) Then + ShowWithoutActivating HelpWin.hWnd + End If End Sub Private Sub ShowHelp() @@ -282,20 +351,37 @@ Private Class HelpAddIn Case 1 ShowPage Index.Url(pages(1)) Case Else - SearchBox.Properties.value = word + SetSearchText word LastSearch = word ShowResults pages - Pane.Visible = True + ShowView End Select End Sub + ' Puts text in the search box, in whichever view shows it. + Private Sub SetSearchText(ByVal text As String) + SearchText = text + SearchBox.Properties.value = text + If Detached Then HelpWin.Post "search:" & text + End Sub + + ' Shows a page in the pane's frame, or in the window's, in place of a + ' summary. The frame that is hidden is not given the page, so that no page + ' is loaded twice; Attach gives it the page it missed. Private Sub ShowPage(ByVal url As String) PageUrl = url - Frame.Properties.src = FrameUrl(url) + ShowingSummary = False Summary.Properties.style.display = "none" Frame.Properties.style.display = "" BrowserButton.Properties.disabled = False - Pane.Visible = True + If Detached Then + HelpWin.Post "page:" & FrameUrl(url) + HelpWin.Post "browser:1" + Else + LoadedFrame = FrameUrl(url) + Frame.Properties.src = LoadedFrame + End If + ShowView SaveSetting SETTINGS_APP, "Pane", "Page", url End Sub @@ -306,14 +392,21 @@ Private Class HelpAddIn Private Sub ShowSummary(ByVal hover As String) Dim declaration As String, description As String, where As String ReadSummary hover, declaration, description, where - Summary.Properties.innerHTML = "
" & HtmlText(declaration) & "
" & _ + SummaryHtml = "
" & HtmlText(declaration) & "
" & _ IIf(Len(description) > 0, "

" & HtmlText(description) & "

", _ "

No description.

") & _ IIf(Len(where) > 0, "

Declared in " & HtmlText(where) & "

", "") + ShowingSummary = True + Summary.Properties.innerHTML = SummaryHtml Frame.Properties.style.display = "none" Summary.Properties.style.display = "" BrowserButton.Properties.disabled = True - Pane.Visible = True + If Detached Then + HelpWin.Post "summary:" & SummaryHtml + HelpWin.Post "browser:0" + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window summary" + End If + ShowView End Sub ' Fills the list with the entries ids, or hides it when there is no search. @@ -326,8 +419,7 @@ Private Class HelpAddIn For Each i In ids ' Only the entry's number goes into the handler; the text is escaped. Results.addItem("
" & HtmlText(Index.Label(i)) & _ - "" & HtmlText(Index.Kind(i)) & "
") + CLng(i) & ")'>" & ResultText(i) & "") Next Dim rows As Long = ids.Count If rows = 0 And Len(LastSearch) > 0 Then @@ -336,12 +428,34 @@ Private Class HelpAddIn End If ResultsElement.Properties.style.height = "calc(" & rows & " * var(--helpRow))" ResultsElement.Properties.style.display = IIf(rows > 0, "", "none") + If Detached Then PostResults + End Sub + + ' A row's label and kind, escaped. + Private Function ResultText(ByVal i As Long) As String + Return "" & HtmlText(Index.Label(i)) & "" & _ + HtmlText(Index.Kind(i)) & "" + End Function + + ' Sends the window the rows ShowResults gave the list. The window's rows + ' carry the entry's number for it to post back; nothing else of the text + ' is trusted. + Private Sub PostResults() + Dim html As String + Dim i As Variant + For Each i In ResultIds + html = html & "
" & ResultText(i) & "
" + Next + If ResultIds.Count = 0 And Len(LastSearch) > 0 Then html = "
No matches
" + HelpWin.Post "results:" & html + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window results " & ResultIds.Count End Sub - ' Searches again when the text has changed. - Private Sub SearchAgain() + ' Searches again when the text, from either view's search box, has changed. + Private Sub SearchAgain(ByVal text As String) If Index Is Nothing Then Exit Sub - Dim text As String = Trim$(SearchBox.Properties.value) + SearchText = text + text = Trim$(text) If text <> LastSearch Then LastSearch = text ShowResults Index.Search(text, SEARCH_LIMIT) @@ -350,7 +464,7 @@ Private Class HelpAddIn Private Sub SearchChanged(ByVal eventInfo As HtmlEventProperties) On Error GoTo Failed - SearchAgain + SearchAgain SearchBox.Properties.value Exit Sub Failed: Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description @@ -359,24 +473,181 @@ Private Class HelpAddIn Private Sub SearchKeyUp(ByVal eventInfo As HtmlEventProperties) On Error GoTo Failed If Index Is Nothing Then Exit Sub - SearchAgain - If eventInfo.key = "Enter" And ResultIds.Count > 0 Then ShowPage Index.Url(ResultIds(1)) + SearchAgain SearchBox.Properties.value + If eventInfo.key = "Enter" Then ShowFirstResult Exit Sub Failed: Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub + ' Enter in a search box shows the first of the results. + Private Sub ShowFirstResult() + If ResultIds.Count > 0 Then ShowPage Index.Url(ResultIds(1)) + End Sub + Private Sub ResultPicked(ByVal eventInfo As HtmlEventProperties) - ShowPage Index.Url(CLng(eventInfo.customData0)) + On Error GoTo Failed + PickResult CLng(eventInfo.customData0) + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Shows the page of entry i, which has to be one of the results: the window + ' names it in a message, which is only as good as the page that posted it. + Private Sub PickResult(ByVal i As Long) + Dim id As Variant + For Each id In ResultIds + If CLng(id) = i Then + ShowPage Index.Url(i) + Exit Sub + End If + Next + End Sub + + Private Sub BrowserClicked(ByVal eventInfo As HtmlEventProperties) + OpenInBrowser End Sub ' Opens the page the add-in last gave the frame, or the site's home page. ' A link followed inside the frame is not seen: the live site is on ' another origin, whose location the IDE's page cannot read. - Private Sub BrowserClicked(ByVal eventInfo As HtmlEventProperties) + Private Sub OpenInBrowser() OpenUrl Host, SiteUrl() & IIf(Len(PageUrl) > 0, PageUrl, "/") End Sub + ' ------------------------------------------------------------ the window + + Private Sub DetachClicked(ByVal eventInfo As HtmlEventProperties) + Detach + End Sub + + ' Hides the pane and shows the window, which is made the first time and + ' kept after, with the page it has. The page says "ready" once it has + ' loaded, and is sent the state then; a window already loaded is sent it + ' now. + Private Sub Detach() + On Error GoTo Failed + Dim first As Boolean = HelpWin Is Nothing + If first Then Set HelpWin = New HelpWindow + Detached = True + Pane.Visible = False + HelpWin.Show vbModeless + If first Then RestoreWindowPlace + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Detached", "1" + If HelpWin.IsReady Then SendWindowState + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "detached" + Exit Sub + Failed: + ' The help stays in the pane. + Detached = False + Pane.Visible = True + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Hides the window and shows the pane again, with the search box as it was + ' left in the window and the page the pane's frame has not been given yet. + Private Sub Attach() + On Error GoTo Failed + SaveWindowPlace + HelpWin.Hide + Detached = False + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Detached", "0" + SearchBox.Properties.value = SearchText + If Len(PageUrl) > 0 And Not ShowingSummary Then + If LoadedFrame <> FrameUrl(PageUrl) Then + LoadedFrame = FrameUrl(PageUrl) + Frame.Properties.src = LoadedFrame + End If + End If + Pane.Visible = True + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "attached" + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' The X asks to close the window, which is Attach. + Private Sub HelpWin_Closing() + Attach + End Sub + + Private Sub HelpWin_Failed(ByVal text As String) + Host.DebugConsole.PrintText TAG & text + End Sub + + ' What the window's page posts: "verb:payload" (shell.js). + Private Sub HelpWin_Message(ByVal text As String) + On Error GoTo Failed + Dim verb As String = text + Dim payload As String + Dim colon As Long = InStr(text, ":") + If colon > 0 Then + verb = Left$(text, colon - 1) + payload = Mid$(text, colon + 1) + End If + Select Case verb + Case "ready" + If Detached Then SendWindowState + Case "search" + SearchAgain payload + Case "enter" + ShowFirstResult + Case "pick" + PickResult CLng(payload) + Case "hover" + SetHover payload = "1" + Case "attach" + Attach + Case "browser" + OpenInBrowser + Case "frame" + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window page " & payload + End Select + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Gives the window all that the pane shows: the theme, the search box, the + ' Hover help box, the results, and the page or the summary. + Private Sub SendWindowState() + HelpWin.Post "theme:" & ThemeMessage() + HelpWin.Post "hoverdisabled:" & IIf(Hovering Is Nothing, "1", "0") + HelpWin.Post "hover:" & IIf(HoverOn, "1", "0") + HelpWin.Post "search:" & SearchText + PostResults + If ShowingSummary Then + HelpWin.Post "summary:" & SummaryHtml + HelpWin.Post "browser:0" + Else + If Len(PageUrl) > 0 Then HelpWin.Post "page:" & FrameUrl(PageUrl) + HelpWin.Post "browser:1" + End If + End Sub + + ' The window's place is kept from one session to the next: its position + ' and size in pixels. + Private Sub SaveWindowPlace() + On Error Resume Next + Dim x As Long, y As Long, w As Long, h As Long + If Not WindowPlace(HelpWin.hWnd, x, y, w, h) Then Exit Sub + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Left", CStr(x) + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Top", CStr(y) + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Width", CStr(w) + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Height", CStr(h) + End Sub + + ' Puts the window where it was, if that is on a monitor that is still + ' there; otherwise it stays where Windows put it. + Private Sub RestoreWindowPlace() + Dim x As Long = Val(GetSetting(SETTINGS_APP, WINDOW_SECTION, "Left", "0")) + Dim y As Long = Val(GetSetting(SETTINGS_APP, WINDOW_SECTION, "Top", "0")) + Dim w As Long = Val(GetSetting(SETTINGS_APP, WINDOW_SECTION, "Width", "0")) + Dim h As Long = Val(GetSetting(SETTINGS_APP, WINDOW_SECTION, "Height", "0")) + If w > 0 And h > 0 And IsOnScreen(x, y, w, h) Then PlaceWindow HelpWin.hWnd, x, y, w, h + End Sub + ' The selection when it is on one line, line then -1; else the name under ' the cursor, with line and character the place of its character there ' that the compiler is asked about, both counted from 0, as it counts. diff --git a/add-in/Sources/HelpWindow.tbform b/add-in/Sources/HelpWindow.tbform new file mode 100644 index 000000000..40c4f79c3 --- /dev/null +++ b/add-in/Sources/HelpWindow.tbform @@ -0,0 +1,51 @@ +[ + { + "Appearance": "vbAppear3d", + "BorderStyle": "vbSizable", + "Caption": "twinBASIC Help", + "ControlBox": true, + "Enabled": true, + "FormDesignerId": "{4581D804-116B-436E-B7AB-1E4417F9703D}", + "Height": 640, + "Left": 0, + "MaxButton": true, + "MinButton": true, + "Name": "HelpWindow", + "ScaleHeight": 9600, + "ScaleMode": "vbTwips", + "ScaleWidth": 7200, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "Top": 0, + "Visible": true, + "Width": 480, + "_children": [ + { + "Anchors": { + "Bottom": true, + "Left": true, + "Right": true, + "Top": true, + "_clsid": "{8524D4B5-72A9-40A9-A189-29E7905C40CA}" + }, + "DocumentURL": "", + "EnvironmentOptions": { + "UserDataFolder": "", + "_className": "WebView2EnvironmentOptions", + "_clsid": "{185E63C0-E641-47ED-B6A9-8565168A7FCF}" + }, + "Height": 640, + "Index": -1, + "Left": 0, + "Name": "WebView", + "Top": 0, + "Visible": true, + "Width": 480, + "_className": "WebView2", + "_clsid": "{93B7E07E-6C7F-4758-8E51-4B1B17EC0A62}" + } + ], + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/add-in/Sources/HelpWindow.twin b/add-in/Sources/HelpWindow.twin new file mode 100644 index 000000000..5bc3d29a9 --- /dev/null +++ b/add-in/Sources/HelpWindow.twin @@ -0,0 +1,100 @@ +' The detached help window: a top-level window of the add-in's own, with one +' WebView2 on it, which shows the whole help pane (Resources/SHELL). It knows +' nothing of the help itself. What the page posts comes out as Message, what the +' add-in posts goes in with Post, and closing it with the X raises Closing and +' leaves the window to the add-in, which hides it. It has no owner window, so +' the user can move it to another monitor and the IDE does not cover it. +[FormDesignerId("4581D804-116B-436E-B7AB-1E4417F9703D")] +Class HelpWindow + Public Event Message(ByVal text As String) + Public Event Closing() + Public Event Failed(ByVal text As String) + + Private PageReady As Boolean + + Public Sub New() + End Sub + + ' Whether the page has loaded and posted "ready", which is when Post starts + ' to reach it. + Public Property Get IsReady() As Boolean + Return PageReady + End Property + + ' Sends the page a message. One sent before the page is ready has nowhere + ' to go, and is dropped: the add-in sends the whole state when the page + ' says "ready". + Public Sub Post(ByVal text As String) + If PageReady Then WebView.PostWebMessage text + End Sub + + ' The control's environment, which has to be set before it is built. A + ' folder of its own keeps the control from sharing the IDE's, which the + ' runtime refuses with different options. The harness gives the IDE + ' WEBVIEW2_USER_DATA_FOLDER and WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, the + ' compiler inherits them, and the runtime lets them override the control's + ' own settings: the control then fails with error 8007139F, "Error occurred + ' creating the WebView2 controller". So under the test switch they are + ' removed, and the lane may ask for a debugging port of the window's own, + ' to read its page with. + Private Sub WebView_Create() Handles WebView.Create + If TestSwitchOn() Then + ClearEnvironmentVariable "WEBVIEW2_USER_DATA_FOLDER" + ClearEnvironmentVariable "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS" + WebView.EnvironmentOptions.UserDataFolder = Environ$("TEMP") & "\tbDocsHelp" + Dim port As String = Environ$("TB_DOCS_HELP_WINDOW_PORT") + If Len(port) > 0 Then + WebView.EnvironmentOptions.AdditionalBrowserArguments = _ + "--remote-debugging-port=" & port & " --remote-allow-origins=*" + End If + Else + Dim folder As String = Environ$("LOCALAPPDATA") & "\tbDocsHelp" + On Error Resume Next + MkDir folder + MkDir folder & "\WebView2" + On Error GoTo 0 + WebView.EnvironmentOptions.UserDataFolder = folder & "\WebView2" + End If + End Sub + + Private Sub WebView_Error(ByVal code As Long, ByVal msg As String) Handles WebView.Error + RaiseEvent Failed("window error " & Hex$(code) & ": " & msg) + End Sub + + Private Sub WebView_Ready() Handles WebView.Ready + On Error GoTo Failed + WebView.NavigateToString ShellHtml() + Exit Sub + Failed: + RaiseEvent Failed("window error " & Err.Number & ": " & Err.Description) + End Sub + + Private Sub WebView_JsMessage(ByVal Message As Variant) Handles WebView.JsMessage + Dim text As String = CStr(Message) + If text = "ready" Then PageReady = True + RaiseEvent Message(text) + End Sub + + ' The X, and the window menu's Close, only ask to close: the window is + ' hidden and shown again, so that its page is kept. Anything else, as the + ' add-in unloading it, goes through. + Private Sub Form_QueryUnload(Cancel As Integer, UnloadMode As Integer) + If UnloadMode = vbFormControlMenu Then + Cancel = True + RaiseEvent Closing + End If + End Sub + + ' The page: the pane's stylesheet and the window's, and the script, in the + ' one document NavigateToString loads. + Private Function ShellHtml() As String + Dim html As String = ResourceText("shell.html", "SHELL") + html = Replace(html, "$PANE_CSS$", ResourceText("pane.css", "STYLESHEETS")) + html = Replace(html, "$SHELL_CSS$", ResourceText("shell.css", "SHELL")) + Return Replace(html, "$SCRIPT$", ResourceText("shell.js", "SHELL")) + End Function + + Private Function ResourceText(ByVal name As String, ByVal folder As String) As String + Return StrConv(LoadResDataInternal(name, folder), VbStrConv.vbFromUTF8) + End Function +End Class diff --git a/add-in/Sources/ThemeCss.twin b/add-in/Sources/ThemeCss.twin new file mode 100644 index 000000000..9d594616e --- /dev/null +++ b/add-in/Sources/ThemeCss.twin @@ -0,0 +1,140 @@ +' The IDE theme as CSS, for the detached help window, whose page is not the +' IDE's and so does not have the IDE's custom properties. The IDE reads a +' theme file into the properties of its document: "Name: value;" becomes +' --themeName, a value ending in !important is set as important, and the file's +' "inherits: other;" line pulls in another theme first, so that the file's own +' lines override it. A theme that inherits from nothing and is not Light or +' Dark inherits from Dark. This does the same, for the pane's own colours and +' the rest, as one :root rule. +Module ThemeCss + Private Const MOST_PARENTS As Long = 8 + + ' The rule for the theme named themeName: its own properties, then each + ' parent's that it did not set. "" when the theme's file cannot be found, + ' which leaves the page to its plain colours. + Public Function ThemeRule(ByVal themeName As String) As String + Dim seen As New Collection + Dim properties As String + Dim name As String = themeName + Dim depth As Long + Do While Len(name) > 0 And depth < MOST_PARENTS + Dim text As String = ReadTheme(name) + If Len(text) = 0 Then Exit Do + text = WithoutComments(text) + properties = properties & Declarations(text, seen) + name = ParentOf(text, name) + depth = depth + 1 + Loop + If Len(properties) = 0 Then Return "" + Return ":root{" & properties & "}" + End Function + + ' The theme the file inherits from, or Dark for a theme that names none, + ' and "" for Light and Dark themselves. + Private Function ParentOf(ByVal text As String, ByVal name As String) As String + Dim at As Long = InStr(1, vbLf & text, vbLf & "inherits:", vbTextCompare) + If at > 0 Then + Dim start As Long = at + Len("inherits:") + Dim finish As Long = InStr(start, text, ";") + If finish > 0 Then + Dim parent As String = Mid$(text, start, finish - start) + Dim white As Variant + For Each white In Array(" ", vbTab, vbCr, vbLf) + parent = Replace(parent, white, "") + Next + Return parent + End If + End If + Select Case LCase$(name) + Case "light", "dark" + Return "" + End Select + Return "dark" + End Function + + ' Each "Name: value;" of text as a custom property, in the order of the + ' file, leaving out the names seen already and adding the new ones to seen. + ' A statement ends at a semicolon followed by white space or the end of the + ' text, as the IDE reads it, so that a semicolon inside a value stays in it. + Private Function Declarations(ByVal text As String, ByVal seen As Collection) As String + Dim start As Long = 1 + Dim i As Long + Dim n As Long = Len(text) + For i = 1 To n + If Mid$(text, i, 1) = ";" Then + If i = n Or InStr(" " & vbTab & vbCr & vbLf, Mid$(text, i + 1, 1)) > 0 Then + Declarations = Declarations & Declaration(Mid$(text, start, i - start), seen) + start = i + 1 + End If + End If + Next + End Function + + ' One statement as "--themeName:value;", or "" when it is not a property, + ' is the inherits line, or sets a name an earlier statement set. + Private Function Declaration(ByVal statement As String, ByVal seen As Collection) As String + statement = Replace(Replace(statement, vbCr, ""), vbLf, "") + Dim colon As Long = InStr(statement, ": ") + Dim tab As Long = InStr(statement, ":" & vbTab) + If colon = 0 Or (tab > 0 And tab < colon) Then colon = tab + If colon = 0 Then Return "" + Dim name As String = Trim$(Left$(statement, colon - 1)) + Dim value As String = Trim$(Mid$(statement, colon + 1)) + If Len(name) = 0 Or Len(value) = 0 Or LCase$(name) = "inherits" Then Return "" + If Not IsName(name) Then Return "" + On Error Resume Next + seen.Add True, name + If Err.Number <> 0 Then Return "" + On Error GoTo 0 + Return "--theme" & name & ":" & value & ";" + End Function + + Private Function IsName(ByVal text As String) As Boolean + Dim i As Long + For i = 1 To Len(text) + Select Case AscW(Mid$(text, i, 1)) + Case 48 To 57, 65 To 90, 95, 97 To 122 + Case Else + Return False + End Select + Next + Return True + End Function + + Private Function WithoutComments(ByVal text As String) As String + Dim start As Long = InStr(text, "/*") + Do While start > 0 + Dim finish As Long = InStr(start + 2, text, "*/") + If finish = 0 Then Return Left$(text, start - 1) + text = Left$(text, start - 1) & Mid$(text, finish + 2) + start = InStr(start, text, "/*") + Loop + Return text + End Function + + ' The text of the theme's file: the install's themes folder first, then the + ' user's. A name that is not a plain file name is refused, since a theme's + ' inherits line names the next file. + Private Function ReadTheme(ByVal name As String) As String + If InStr(name, "\") > 0 Or InStr(name, "/") > 0 Or InStr(name, ":") > 0 Then Return "" + Dim folder As Variant + For Each folder In Array(InstallFolder() & "\themes", Environ$("APPDATA") & "\twinBASIC\themes") + Dim path As String = folder & "\" & name & ".theme" + If Len(Dir$(path)) > 0 Then Return ReadFile(path) + Next + Return "" + End Function + + Private Function ReadFile(ByVal path As String) As String + Dim file As Long = FreeFile + Open path For Binary Access Read As #file + Dim size As Long = LOF(file) + If size > 0 Then + Dim data() As Byte + ReDim data(0 To size - 1) + Get #file, , data + ReadFile = StrConv(data, VbStrConv.vbFromUTF8) + End If + Close #file + End Function +End Module diff --git a/add-in/Sources/WindowApi.twin b/add-in/Sources/WindowApi.twin new file mode 100644 index 000000000..4314fa98c --- /dev/null +++ b/add-in/Sources/WindowApi.twin @@ -0,0 +1,87 @@ +' The Win32 calls the detached help window needs: where the compiler is, the +' window's place on the screen, showing it without taking the focus, and the +' compiler's environment, so that no other file declares a Win32 function. +Module WindowApi + Private Type RECT + Left As Long + Top As Long + Right As Long + Bottom As Long + End Type + + Private Declare PtrSafe Function GetModuleFileNameW Lib "kernel32" (ByVal hModule As LongPtr, _ + ByVal lpFilename As LongPtr, ByVal nSize As Long) As Long + Private Declare PtrSafe Function SetEnvironmentVariableW Lib "kernel32" (ByVal lpName As LongPtr, _ + ByVal lpValue As LongPtr) As Long + Private Declare PtrSafe Function IsIconic Lib "user32" (ByVal hWnd As LongPtr) As Long + Private Declare PtrSafe Function ShowWindow Lib "user32" (ByVal hWnd As LongPtr, ByVal nCmdShow As Long) As Long + Private Declare PtrSafe Function GetWindowRect Lib "user32" (ByVal hWnd As LongPtr, ByRef lpRect As RECT) As Long + Private Declare PtrSafe Function MoveWindow Lib "user32" (ByVal hWnd As LongPtr, ByVal X As Long, _ + ByVal Y As Long, ByVal nWidth As Long, ByVal nHeight As Long, ByVal bRepaint As Long) As Long + Private Declare PtrSafe Function MonitorFromRect Lib "user32" (ByRef lprc As RECT, ByVal dwFlags As Long) As LongPtr + + Private Const MAX_PATH As Long = 520 + Private Const SW_SHOWNOACTIVATE As Long = 4 + Private Const MONITOR_DEFAULTTONULL As Long = 0 + + ' The folder of the twinBASIC install, the parent of the bin folder the + ' compiler's executable is in. The add-in runs in the compiler's process. + Public Function InstallFolder() As String + Dim buffer As String = String$(MAX_PATH, vbNullChar) + Dim n As Long = GetModuleFileNameW(0, StrPtr(buffer), MAX_PATH) + Dim path As String = Left$(buffer, n) + Dim bin As Long = InStrRev(path, "\") + If bin = 0 Then Return "" + path = Left$(path, bin - 1) + bin = InStrRev(path, "\") + If bin = 0 Then Return "" + Return Left$(path, bin - 1) + End Function + + ' Removes a variable from the compiler's own environment, which every + ' WebView2 control it makes reads. + Public Sub ClearEnvironmentVariable(ByVal name As String) + SetEnvironmentVariableW StrPtr(name), 0 + End Sub + + Public Function IsMinimized(ByVal hWnd As LongPtr) As Boolean + Return IsIconic(hWnd) <> 0 + End Function + + ' Shows a window in its last size and place, as it was before it was + ' minimized, without activating it. + Public Sub ShowWithoutActivating(ByVal hWnd As LongPtr) + ShowWindow hWnd, SW_SHOWNOACTIVATE + End Sub + + ' The window's rectangle on the screen, in pixels. False when the window + ' has none, or is minimized, whose rectangle is not a place. + Public Function WindowPlace(ByVal hWnd As LongPtr, ByRef Left As Long, ByRef Top As Long, _ + ByRef Width As Long, ByRef Height As Long) As Boolean + If IsIconic(hWnd) <> 0 Then Return False + Dim r As RECT + If GetWindowRect(hWnd, r) = 0 Then Return False + Left = r.Left + Top = r.Top + Width = r.Right - r.Left + Height = r.Bottom - r.Top + Return True + End Function + + Public Sub PlaceWindow(ByVal hWnd As LongPtr, ByVal Left As Long, ByVal Top As Long, _ + ByVal Width As Long, ByVal Height As Long) + MoveWindow hWnd, Left, Top, Width, Height, 1 + End Sub + + ' Whether a rectangle touches a monitor, so that a window saved on a monitor + ' that has since been unplugged is not put out of reach. + Public Function IsOnScreen(ByVal Left As Long, ByVal Top As Long, ByVal Width As Long, _ + ByVal Height As Long) As Boolean + Dim r As RECT + r.Left = Left + r.Top = Top + r.Right = Left + Width + r.Bottom = Top + Height + Return MonitorFromRect(r, MONITOR_DEFAULTTONULL) <> 0 + End Function +End Module diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 5cf635027..a9cd9a638 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -1263,13 +1263,15 @@ and patch a built DLL's export name. One more checks that the environment variab runner sets, which keeps an add-in under test from opening a browser, reaches the add-in, also after a compiler restart. Another times what the help add-in's hover help costs, a widget in the code editor and polling the cursor, and checks that a hover provider added -through the page shows in the IDE's own hover. The last two lanes test the help add-in in `add-in/`, which +through the page shows in the IDE's own hover. Another checks that an add-in can show a +window of its own, a form holding a WebView2 control. The last two lanes test the help add-in in `add-in/`, which opens the page for the name under the cursor and, with its *Hover help* box ticked, shows -links to a name's pages in the mouse hover, with the copy of the symbol index committed in +links to a name's pages in the mouse hover, and whose pane can be detached into a window of +its own, with the copy of the symbol index committed in `add-in/Resources/SYMBOLS/`: `help` with the pages from the built site, and `help-offline` with the pages from the add-in's own server, built with an archive of the built offline tree that the lane writes with the same [help archive](#the-help-archive) writer the build -uses. The fourteen lanes take about two minutes together. +uses. The fifteen lanes take about two minutes together. | Flag | Effect | |---|---| diff --git a/test/addin/detach.test.mjs b/test/addin/detach.test.mjs new file mode 100644 index 000000000..0a3e3f168 --- /dev/null +++ b/test/addin/detach.test.mjs @@ -0,0 +1,222 @@ +// Can an add-in show a Form holding the WebView2 control as a top-level window, +// which the user could drag to another monitor? The DetachProbe add-in +// (probes/detach) builds a Form with a WebView2 on it and shows it modeless +// from a toolbar button. It prints what the window is (its handle, owning +// process and visibility), when the WebView2 is ready, every message that +// passes between the page and the add-in, and what Hide, Show and Unload do. +// This file serves a page of its own on localhost, which the add-in's page +// loads in an iframe (TB_DETACH_URL), clicks the buttons and reads the DEBUG +// CONSOLE. +// +// The WebView2's user data folder is set to %TEMP%\tbDetachProbe in the +// control's Create event, as an add-in has to: left empty, the runtime picks a +// folder beside the host executable, which here is the compiler in the install. +// The tests also count the msedgewebview2.exe processes under that folder +// before the IDE starts and after it ends. +// +// Each test states what BETA 997 does. If one fails after an IDE update, the +// IDE has changed: update the plan for detaching the help pane in +// WIP.HelpAddin.md, and then this file. +// +// Run it with addin-test.bat, which gives it a lane; on its own it is skipped. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { sleep } from "../../scripts/lib/tb-ide.mjs"; +import { loadedAddins } from "../../scripts/lib/tb-ide-addins.mjs"; +import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs"; +import { click, waitFor } from "../../scripts/lib/tb-operate.mjs"; +import { serveLoopback } from "./pages.mjs"; +import { scenario } from "./scenario.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const HOST = path.join(HERE, "host"); +const PROBE = path.join(HERE, "probes", "detach"); +// The IDE's TEMP is a folder of the harness's own (launchIde in tb-ide.mjs), and +// the add-in builds its user data folder from the TEMP it finds. +const ideTemp = (lane) => path.join(process.env.TEMP ?? tmpdir(), `tbbuild-tmp-${lane.port}`); +// DETACH_NOUDF=1 leaves the control's user data folder unset: the variant that +// shows where the runtime puts it then. +const NOUDF = !!process.env.DETACH_NOUDF; + +// What the probe printed since a mark, without its prefix. +const probeLines = (c, mark) => linesSince(c, mark, { prefix: "[DetachProbe] " }); +const lineWith = (lines, re) => lines.find((l) => re.test(l)); +const waitLine = (c, mark, re, timeout = 30 * 1000) => + waitFor( + c, + async (c) => { + const l = lineWith(await probeLines(c, mark), re); + return l ?? false; + }, + { timeout }, + ); + +const ps = (script) => + execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + windowsHide: true, + }).trim(); + +// The ids of the msedgewebview2.exe processes started under the probe's user data folder. +const probeBrowsers = () => + ps( + `Get-CimInstance Win32_Process -Filter "name='msedgewebview2.exe'" | ` + + `Where-Object { $_.CommandLine -like '*tbDetachProbe*' } | ForEach-Object { $_.ProcessId }`, + ) + .split(/\s+/) + .filter(Boolean) + .map(Number); + +// "|" of a process, or "" when it is gone. +const processOf = (pid) => + ps( + `Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | ` + + `ForEach-Object { $_.Name + '|' + $_.ParentProcessId }`, + ); + +// Every folder in the copy of the install whose name ends in .WebView2, with +// what it holds one level down. +const webView2Folders = (root) => + existsSync(root) + ? readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((e) => e.isDirectory() && /\.WebView2$/i.test(e.name)) + .map((e) => path.join(e.parentPath ?? e.path, e.name)) + .map((f) => `${path.relative(root, f)} [${readdirSync(f).join(", ")}]`) + : []; + +// The --user-data-dir of a browser process. +const userDataDir = (pid) => + ps(`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`).match( + /--user-data-dir="?([^"]+?)"?(?= --|$)/, + )?.[1]; + +scenario("a Form holding a WebView2, shown by an add-in", (lane) => { + let c, server, origin, before0, root, beforeFolders, formPid; + const requests = []; + before(async () => { + server = await serveLoopback((req, res) => { + requests.push({ url: req.url, dest: req.headers["sec-fetch-dest"] }); + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + res.end("Detach fixture

fixture page

"); + }); + origin = `http://localhost:${server.port}/`; + before0 = probeBrowsers(); + await lane.addAddin(PROBE); + root = path.dirname(lane.copy()); + const env = { TB_DETACH_URL: `${origin}fixture.html` }; + if (NOUDF) env.TB_DETACH_NOUDF = "1"; + c = await lane.open(HOST, { env }); + beforeFolders = webView2Folders(root); + }); + + test("the add-in builds with a Form and a WebView2, loads, and adds its buttons", async () => { + const names = (await loadedAddins(c)).map((a) => a.name); + assert.ok(names.includes("DetachProbe AddIn"), `loaded: ${JSON.stringify(names)}`); + assert.ok(await waitLine(c, null, /^loaded$/), "the add-in did not load"); + }); + + test("Show makes a visible top-level window, owned by the compiler's process", async (t) => { + const mark = await consoleMark(c); + await click(c, "addinButton-detachProbe"); + assert.ok(await waitLine(c, mark, /^window pid /), `no window: ${JSON.stringify(await probeLines(c, mark))}`); + const lines = await probeLines(c, mark); + t.diagnostic(lines.join(" | ")); + const m = lineWith(lines, /^window pid /).match(/^window pid (\d+) current pid (\d+)$/); + assert.ok(m, JSON.stringify(lines)); + formPid = Number(m[1]); + assert.equal(formPid, Number(m[2]), "the form belongs to a process other than the add-in's"); + const owner = processOf(formPid); + t.diagnostic(`owner ${formPid}: ${owner}; the IDE is ${lane.run.pid}: ${processOf(lane.run.pid)}`); + assert.match(owner, /^twinBASIC.*\.exe\|/i); + assert.equal(lineWith(lines, /^visible /), "visible 1"); + const rect = lineWith(lines, /^rect /).match(/^rect (-?\d+),(-?\d+),(-?\d+),(-?\d+)$/); + assert.ok( + rect && Number(rect[3]) - Number(rect[1]) > 100 && Number(rect[4]) - Number(rect[2]) > 100, + lines.join("|"), + ); + }); + + test("the WebView2 becomes ready, and the page and the add-in exchange messages", async (t) => { + const ready = await waitLine(c, null, /^ready \d+$/); + assert.ok(ready, `not ready: ${JSON.stringify(await probeLines(c, null))}`); + t.diagnostic(ready); + assert.ok(await waitLine(c, null, /^msg frame-loaded:/), `no frame: ${JSON.stringify(await probeLines(c, null))}`); + const lines = await probeLines(c, null); + t.diagnostic(lines.join(" | ")); + const dir = userDataDir(lineWith(lines, /^browser pid /).slice(12)); + t.diagnostic(`user data dir of the browser: ${dir}`); + const expected = NOUDF + ? path.join(root, "bin", "twinBASIC_win32_noDEP.exe.WebView2", "EBWebView") + : path.join(ideTemp(lane), "tbDetachProbe", "EBWebView"); + assert.equal(dir?.toLowerCase(), expected.toLowerCase()); + assert.equal(lineWith(lines, /^error /), undefined); + for (const want of ["msg shell-loaded", "msg echo:ping", `msg frame-loaded:${origin}fixture.html`]) { + assert.ok(lines.includes(want), `${want} is not in ${JSON.stringify(lines)}`); + } + assert.ok( + requests.some((r) => r.url === "/fixture.html" && r.dest === "iframe"), + JSON.stringify(requests), + ); + if (!NOUDF) { + const folder = path.join(ideTemp(lane), "tbDetachProbe"); + assert.ok(existsSync(folder), `${folder} was not made`); + t.diagnostic(`${folder}: ${readdirSync(folder).join(", ")}`); + } + }); + + test("nothing is written into the copy of the install", (t) => { + const now = webView2Folders(root); + t.diagnostic(`WebView2 folders in the copy: before ${JSON.stringify(beforeFolders)}, now ${JSON.stringify(now)}`); + assert.deepEqual(now, beforeFolders); + }); + + test("Hide hides the window, Show shows it again, and the page stays loaded", async () => { + let mark = await consoleMark(c); + await click(c, "addinButton-detachProbeHide"); + assert.ok(await waitLine(c, mark, /^visible /)); + assert.deepEqual(await probeLines(c, mark), ["hidden", "visible 0"]); + mark = await consoleMark(c); + await click(c, "addinButton-detachProbeShow"); + assert.ok(await waitLine(c, mark, /^visible /)); + assert.deepEqual(await probeLines(c, mark), ["shown", "visible 1"]); + // The page was not reloaded: no second shell-loaded, no second ready. + await sleep(2000); + const lines = await probeLines(c, null); + assert.equal(lines.filter((l) => l === "msg shell-loaded").length, 1); + assert.equal(lines.filter((l) => /^ready /.test(l)).length, 1); + }); + + test("Unload ends the form, and the button makes a fresh one", async (t) => { + let mark = await consoleMark(c); + await click(c, "addinButton-detachProbeUnload"); + assert.ok(await waitLine(c, mark, /^unloaded$/)); + t.diagnostic((await probeLines(c, mark)).join(" | ")); + mark = await consoleMark(c); + await click(c, "addinButton-detachProbe"); + assert.ok(await waitLine(c, mark, /^msg frame-loaded:/), JSON.stringify(await probeLines(c, mark))); + const lines = await probeLines(c, mark); + t.diagnostic(lines.join(" | ")); + assert.ok(lines.includes("msg shell-loaded") && lines.includes("msg echo:ping"), JSON.stringify(lines)); + assert.equal(lines.filter((l) => /^ready /.test(l)).length, 1); + }); + + // After the lane is closed, even when closing it fails. + return async () => { + const at = probeBrowsers().length; + let later = at; + for (let i = 0; i < 20 && later > 0; i++) { + await sleep(500); + later = probeBrowsers().length; + } + console.log( + `msedgewebview2.exe under tbDetachProbe: before ${before0.length}, when the IDE ended ${at}, ten seconds later ${later}`, + ); + server?.close(); + }; +}); diff --git a/test/addin/help.test.mjs b/test/addin/help.test.mjs index f07331ea3..2a4743d85 100644 --- a/test/addin/help.test.mjs +++ b/test/addin/help.test.mjs @@ -16,6 +16,12 @@ // Each case is a line and column in helphost/Sources/Cases.twin, so a change to // that file is a change to this table. // +// The last cases are the Detach button, which moves the pane into a window of +// the add-in's own, a Form holding a WebView2 control. The window's page is +// read and operated over DevTools: the add-in starts the control with the +// debugging port in TB_DOCS_HELP_WINDOW_PORT, which only the test switch +// allows, and prints "window page " when the frame in it has loaded. +// // Run it with addin-test.bat, which gives it a lane; on its own it is skipped. import assert from "node:assert/strict"; @@ -26,6 +32,7 @@ import { before, test } from "node:test"; import { fileURLToPath } from "node:url"; import { createStaticHandler } from "../../builder/static-files.mjs"; import { writeHelpArchive } from "../../lib/help-archive.mjs"; +import { attach } from "../../scripts/lib/tb-cdp.mjs"; import { sleep } from "../../scripts/lib/tb-ide.mjs"; import { loadedAddins } from "../../scripts/lib/tb-ide-addins.mjs"; import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs"; @@ -44,6 +51,7 @@ import { typeText, waitFor, } from "../../scripts/lib/tb-operate.mjs"; +import { claimPorts } from "../../scripts/lib/tb-ports.mjs"; import { hoverLink, hoverText, mouseAway, restMouse } from "./hover.mjs"; import { frameEval, frameOf, serveLoopback } from "./pages.mjs"; import { scenario } from "./scenario.mjs"; @@ -148,6 +156,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { let origin; // the site this file serves let pages; // where the frame's pages come from: origin, or the add-in's server let server; + let windowPort; // the detached window's DevTools port const offline = lane.name === "help-offline"; // The frame shows `url` from the built site in the IDE's theme: the add-in @@ -277,7 +286,8 @@ scenario("the help add-in: F1 and the help pane", (lane) => { } } await lane.addAddin(src); - c = await lane.open(HOST, { env: { TB_DOCS_HELP_SITE: origin } }); + [windowPort] = await claimPorts(1, { from: 9760 }); + c = await lane.open(HOST, { env: { TB_DOCS_HELP_SITE: origin, TB_DOCS_HELP_WINDOW_PORT: String(windowPort) } }); pages = await pagesOrigin(null); await openFile(c, FILE, { line: 5, column: 9 }); }); @@ -609,13 +619,19 @@ scenario("the help add-in: F1 and the help pane", (lane) => { })()`); // The hover under the mouse at a place, once the IDE's own text is in it. + // The first hover after a compiler restart can come back empty, while the + // compiler is still starting, and stays empty however long the mouse rests; + // the mouse then rests again, once. async function mouseHover(line, column, want = "in VBA.") { await paneAside(); - await restMouse(c, line, column); - return waitFor(c, async (c) => { - const t = await hoverText(c); - return t?.includes(want) && t; - }); + for (let attempt = 0; ; attempt++) { + await restMouse(c, line, column); + const t = await waitFor(c, async (c) => { + const t = await hoverText(c); + return t?.includes(want) && t; + }); + if (t || attempt === 1) return t; + } } test("hover help is off until its box is ticked", async () => { @@ -702,5 +718,225 @@ scenario("the help add-in: F1 and the help pane", (lane) => { await mouseAway(c); }); - return () => server?.close(); + // ------------------------------------------------------------ the detached window + + let win; // the DevTools connection to the window's page + // The src of the frame in the window, from the add-in's "window page" line + // after a mark. + const windowPage = async (mark) => + (await addinLines(c, mark)).find((l) => l.startsWith("window page "))?.slice("window page ".length) ?? null; + const windowRows = () => + win.evaluate(`[...document.querySelectorAll("#helpResults .hit .label")].map((e) => e.textContent)`); + const windowBox = () => win.evaluate(`document.getElementById("helpHover").checked`); + // The centre of an element of the window's page, and a click there. + async function clickInWindow(css) { + const p = await win.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(css)}) + .getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); + await clickAt(win, p.x, p.y); + } + // The same theme properties in the IDE and in the window's page; the IDE + // sets them on its document, the add-in on the window's from the theme's + // files. None is in the window's plain palette. + const THEME_PROPERTIES = ["CodePanelBackColor", "MenuBackColor", "TreeItemHoverBackColor", "ErrorTabTextColor"]; + const themeProperties = (conn) => + conn.evaluate( + `${JSON.stringify(THEME_PROPERTIES)}.map((n) => getComputedStyle(document.documentElement).getPropertyValue("--theme" + n).trim())`, + ); + + // Click Detach in the pane, and wait for the window to say "detached". + async function detach() { + const mark = await consoleMark(c); + await click(c, { toolWindow: PANE, css: "#helpDetach" }); + assert.ok( + await waitFor(c, async (c) => (await addinLines(c, mark)).includes("detached"), { timeout: 30 * 1000 }), + `the add-in did not detach: ${JSON.stringify(await addinLines(c, mark))}`, + ); + return mark; + } + + // Connect to the window's page, which the control starts with the debugging + // port: the page is there once the window has made its control. + async function connectWindow() { + win?.close(); + win = null; + const connected = await waitFor( + c, + async () => { + try { + win = await attach(windowPort, "", { timeout: 30 * 1000 }); + return true; + } catch { + return false; + } + }, + { timeout: 30 * 1000 }, + ); + assert.ok(connected, `no page on the window's debugging port ${windowPort}`); + await win.send("Emulation.setFocusEmulationEnabled", { enabled: true }); + } + + test("Detach hides the pane and shows the window with the page the pane had, and the pane's frame stays", async () => { + const was = await frameSrc(c); + assert.ok(was, "the pane had no page to carry over"); + const mark = await detach(); + assert.equal(await waitFor(c, () => windowPage(mark), { timeout: 30 * 1000 }), was); + assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is still showing"); + assert.equal(await frameSrc(c), was); + await connectWindow(); + assert.equal(await win.evaluate(`document.getElementById("helpPage").src`), was); + assert.equal(await win.evaluate(`document.title`), "twinBASIC Help"); + }); + + test("the window has the IDE theme's properties", async () => { + const ide = await themeProperties(c); + assert.ok( + ide.every((v) => v), + JSON.stringify(ide), + ); + assert.deepEqual(await themeProperties(win), ide); + assert.equal( + await win.evaluate(`document.documentElement.getAttribute("data-group")`), + await themeGroup(c), + "the window's group", + ); + }); + + test("F1 shows the page in the window, and the pane is left alone", async () => { + const pane = await frameSrc(c); + await at(c, 7, 21); + const mark = await consoleMark(c); + await pressKey(c, "F1"); + const want = `${pages}/tB/Modules/Strings/Len?theme=${await themeGroup(c)}&pane=1`; + assert.equal(await waitFor(c, () => windowPage(mark)), want); + assert.equal(await win.evaluate(`document.getElementById("helpPage").src`), want); + assert.equal(await frameSrc(c), pane, "the pane's frame changed"); + assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is showing"); + assert.deepEqual(await addinLines(c, mark), [`window page ${want}`]); + }); + + test("typing in the window's search box lists the results there, and Enter shows the first", async () => { + const mark = await consoleMark(c); + await win.evaluate(`document.getElementById("helpSearch").focus()`); + await typeText(win, "msgbo"); + const rows = await waitFor(c, async () => { + const r = await windowRows(); + return r[0] === "Interaction.MsgBox" && r; + }); + assert.ok(rows, JSON.stringify(await windowRows())); + assert.ok((await addinLines(c, mark)).some((l) => /^window results \d+$/.test(l))); + const enter = await consoleMark(c); + await pressKey(win, "Enter"); + assert.equal( + await waitFor(c, () => windowPage(enter)), + `${pages}/tB/Modules/Interaction/MsgBox?theme=${await themeGroup(c)}&pane=1`, + ); + }); + + test("a click on a result in the window shows its page there", async () => { + const rows = await windowRows(); + const n = rows.indexOf("Interaction.MsgBox"); + assert.ok(n >= 0, JSON.stringify(rows)); + const mark = await consoleMark(c); + // The first row is the page shown already; the next is another entry. + await clickInWindow(`#helpResults .hit:nth-child(${n === 0 ? 2 : 1})`); + const got = await waitFor(c, () => windowPage(mark)); + assert.ok(got?.startsWith(`${pages}/tB/`) && !got.includes("/Interaction/MsgBox?"), String(got)); + assert.equal((await toolWindow(c, PANE))?.visible, false); + }); + + test("Hover help can be ticked in the window, and the pane's box follows", async () => { + assert.equal(await hoverBox(), false); + await clickInWindow("#helpHover"); + assert.ok(await waitFor(c, async () => (await hoverBox()) === true), "the pane's box is not ticked"); + assert.equal(await windowBox(), true); + await clickInWindow("#helpHover"); + assert.ok(await waitFor(c, async () => (await hoverBox()) === false), "the pane's box is still ticked"); + assert.equal(await windowBox(), false); + }); + + test("a name with no page shows its summary in the window, and Open in browser is disabled there", async () => { + await at(c, 18, 10); + const mark = await consoleMark(c); + await pressKey(c, "F1"); + assert.ok( + await waitFor(c, async (c) => (await addinLines(c, mark)).includes("window summary")), + JSON.stringify(await addinLines(c, mark)), + ); + const shown = await win.evaluate(`({ + declaration: document.querySelector("#helpSummary .declaration")?.textContent, + summary: document.getElementById("helpSummary").style.display, + frame: document.getElementById("helpPage").style.display, + browserDisabled: document.getElementById("helpBrowser").disabled, + })`); + assert.deepEqual(shown, { declaration: "Sub Beep ( )", summary: "", frame: "none", browserDisabled: true }); + }); + + test("a theme change reaches the window", async () => { + const was = await themeGroup(c); + const other = was === "dark" ? "light" : "dark"; + const switchTo = (group) => + c.evaluate( + `executeIdeCommand(${JSON.stringify(group === "dark" ? "tbTheme_SwitchToDarkMode" : "tbTheme_SwitchToLightMode")})`, + ); + try { + await switchTo(other); + assert.equal(await themeGroup(c), other, "the IDE's theme did not change"); + const sameAsIde = await waitFor( + c, + async () => JSON.stringify(await themeProperties(win)) === JSON.stringify(await themeProperties(c)), + ); + assert.ok(sameAsIde, "the window's properties are not the new theme's"); + assert.equal(await win.evaluate(`document.documentElement.getAttribute("data-group")`), other); + } finally { + await switchTo(was); + } + assert.ok( + await waitFor( + c, + async () => JSON.stringify(await themeProperties(win)) === JSON.stringify(await themeProperties(c)), + ), + "the window's properties are not the old theme's again", + ); + }); + + test("Attach brings the pane back with the search and the last page", async () => { + await at(c, 7, 21); + const mark = await consoleMark(c); + await pressKey(c, "F1"); + const want = `${pages}/tB/Modules/Strings/Len?theme=${await themeGroup(c)}&pane=1`; + assert.equal(await waitFor(c, () => windowPage(mark)), want); + await clickInWindow("#helpAttach"); + assert.ok( + await waitFor(c, async (c) => (await addinLines(c, mark)).includes("attached")), + JSON.stringify(await addinLines(c, mark)), + ); + assert.ok(await waitFor(c, async (c) => (await toolWindow(c, PANE))?.visible), "the pane is not showing"); + await showsPage("/tB/Modules/Strings/Len"); + assert.equal(await searchValue(c), "msgbo"); + }); + + test("a detached window comes back after a compiler restart", async () => { + await detach(); + const mark = await consoleMark(c); + await lane.restartCompiler(); + pages = await pagesOrigin(mark); + assert.ok( + await waitFor(c, async (c) => (await addinLines(c, mark)).includes("detached"), { timeout: 30 * 1000 }), + JSON.stringify(await addinLines(c, mark)), + ); + const want = `${pages}/tB/Modules/Strings/Len?theme=${await themeGroup(c)}&pane=1`; + assert.equal(await waitFor(c, () => windowPage(mark), { timeout: 30 * 1000 }), want); + assert.equal((await toolWindow(c, PANE))?.visible, false); + await connectWindow(); + const attached = await consoleMark(c); + await clickInWindow("#helpAttach"); + assert.ok(await waitFor(c, async (c) => (await addinLines(c, attached)).includes("attached"))); + assert.ok(await waitFor(c, async (c) => (await toolWindow(c, PANE))?.visible), "the pane is not showing"); + await showsPage("/tB/Modules/Strings/Len"); + }); + + return () => { + win?.close(); + server?.close(); + }; }); diff --git a/test/addin/lanes.mjs b/test/addin/lanes.mjs index 2c9bb6329..125ac6ecc 100644 --- a/test/addin/lanes.mjs +++ b/test/addin/lanes.mjs @@ -35,6 +35,8 @@ export default [ { file: "env.test.mjs" }, // Hover help's two routes: widgets builds its add-in and saves nothing. { file: "widgets.test.mjs" }, + // Detaching the help pane: a Form with a WebView2, shown by an add-in. Saves nothing. + { file: "detach.test.mjs" }, // The help add-in, add-in/ at the repository's root. It saves the page its // pane shows. The second lane builds it with the help archive, so its pane's // pages come from the add-in's own server. diff --git a/test/addin/probes/detach/Settings b/test/addin/probes/detach/Settings new file mode 100644 index 000000000..9a9fcc23a --- /dev/null +++ b/test/addin/probes/detach/Settings @@ -0,0 +1,73 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "DetachProbe", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard DLL", + "project.description": "Can an add-in show a Form holding the WebView2 control as a top-level window? test/addin/detach.test.mjs builds it into a lane's copy of the install; never build it into a real install's addins folder.", + "project.exportPathIsV2": true, + "project.id": "{7B247100-0000-4000-9000-7B2471000009}", + "project.name": "DetachProbe", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{5D19653F-F49E-41F2-ABF8-16E473A89A7C}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WebView2 Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WebView2Package", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 3 + }, + { + "hasBeenSplit": true, + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WinNativeForms Package", + "path32": "/Packages/WindowsControlsPackage", + "path64": "/Packages/WindowsControlsPackage", + "publishedDate": "09-FEB-2022", + "publishedTime": "15:48:54", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WindowsControlsPackage", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{99DEC38C-75F6-4488-8EE7-2D52D83881D2}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC IDE Extensibility Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "tbIDE", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 1 + } + ], + "project.settingsVersion": 1, + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/addin/probes/detach/Sources/DetachForm.tbform b/test/addin/probes/detach/Sources/DetachForm.tbform new file mode 100644 index 000000000..1453ed423 --- /dev/null +++ b/test/addin/probes/detach/Sources/DetachForm.tbform @@ -0,0 +1,51 @@ +[ + { + "Appearance": "vbAppear3d", + "BorderStyle": "vbSizable", + "Caption": "Detach probe", + "ControlBox": true, + "Enabled": true, + "FormDesignerId": "{7B247100-0000-4000-9000-7B24710000F1}", + "Height": 400, + "Left": 0, + "MaxButton": true, + "MinButton": true, + "Name": "DetachForm", + "ScaleHeight": 6000, + "ScaleMode": "vbTwips", + "ScaleWidth": 8000, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "Top": 0, + "Visible": true, + "Width": 600, + "_children": [ + { + "Anchors": { + "Bottom": true, + "Left": true, + "Right": true, + "Top": true, + "_clsid": "{8524D4B5-72A9-40A9-A189-29E7905C40CA}" + }, + "DocumentURL": "", + "EnvironmentOptions": { + "UserDataFolder": "", + "_className": "WebView2EnvironmentOptions", + "_clsid": "{185E63C0-E641-47ED-B6A9-8565168A7FCF}" + }, + "Height": 360, + "Index": -1, + "Left": 10, + "Name": "WebView", + "Top": 10, + "Visible": true, + "Width": 570, + "_className": "WebView2", + "_clsid": "{93B7E07E-6C7F-4758-8E51-4B1B17EC0A62}" + } + ], + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/test/addin/probes/detach/Sources/DetachForm.twin b/test/addin/probes/detach/Sources/DetachForm.twin new file mode 100644 index 000000000..c76d95c05 --- /dev/null +++ b/test/addin/probes/detach/Sources/DetachForm.twin @@ -0,0 +1,54 @@ +[FormDesignerId("7B247100-0000-4000-9000-7B24710000F1")] +Class DetachForm + + Public Sub New() + End Sub + + ' The user data folder is set here, before the environment is built, unless + ' the lane asks for the variant that leaves it empty (TB_DETACH_NOUDF). + Private Sub WebView_Create() Handles WebView.Create + Log "create" + If Len(Environ$("TB_DETACH_NOUDF")) = 0 Then + WebView.EnvironmentOptions.UserDataFolder = Environ$("TEMP") & "\tbDetachProbe" + End If + End Sub + + Private Sub WebView_Error(ByVal code As Long, ByVal msg As String) Handles WebView.Error + Log "error " & Hex$(code) & " " & msg + End Sub + + Private Sub WebView_Ready() Handles WebView.Ready + Log "ready " & (Ticks() - ShowTick) + Log "browser pid " & WebView.BrowserProcessId + WebView.NavigateToString ShellHtml(Environ$("TB_DETACH_URL")) + End Sub + + Private Sub WebView_NavigationComplete(ByVal IsSuccess As Boolean, ByVal WebErrorStatus As Long) Handles WebView.NavigationComplete + Log "navigated " & IsSuccess & " " & WebErrorStatus + End Sub + + Private Sub WebView_JsMessage(ByVal Message As Variant) Handles WebView.JsMessage + Log "msg " & Message + If Message = "shell-loaded" Then WebView.PostWebMessage "ping" + End Sub + + Private Sub Form_Unload(Cancel As Integer) + Log "form unload event" + End Sub + + ' The page: posts shell-loaded, echoes what the host posts, and loads the + ' iframe whose URL the lane passed in. + Private Function ShellHtml(ByVal frameUrl As String) As String + Dim h As String + h = "

shell

" & vbLf + h &= "" + Return h + End Function +End Class diff --git a/test/addin/probes/detach/Sources/MainModule.twin b/test/addin/probes/detach/Sources/MainModule.twin new file mode 100644 index 000000000..09fe4bdd8 --- /dev/null +++ b/test/addin/probes/detach/Sources/MainModule.twin @@ -0,0 +1,132 @@ +' Can an add-in show a Form that holds the WebView2 control as a top-level +' window, and what does it cost? Four toolbar buttons drive it, so that +' detach.test.mjs controls the timing: detachProbe makes a fresh form and shows +' it modeless, detachProbeHide hides it, detachProbeShow shows it again, +' detachProbeUnload unloads it. Every line it prints to the DEBUG CONSOLE +' starts "[DetachProbe]". The page the form loads and the URL of its iframe are +' DetachForm's: the lane passes the URL in TB_DETACH_URL, and TB_DETACH_NOUDF +' (set to anything) leaves the WebView2's user data folder empty. +Module MainModule + Private Type RECT + Left As Long + Top As Long + Right As Long + Bottom As Long + End Type + + Private Declare PtrSafe Function GetCurrentProcessId Lib "kernel32" () As Long + Private Declare PtrSafe Function GetTickCount Lib "kernel32" () As Long + Private Declare PtrSafe Function GetWindowThreadProcessId Lib "user32" (ByVal hWnd As LongPtr, ByRef lpdwProcessId As Long) As Long + Private Declare PtrSafe Function IsWindowVisible Lib "user32" (ByVal hWnd As LongPtr) As Long + Private Declare PtrSafe Function GetWindowRect Lib "user32" (ByVal hWnd As LongPtr, ByRef lpRect As RECT) As Long + + Private Declare PtrSafe Function SetEnvironmentVariableW Lib "kernel32" (ByVal lpName As LongPtr, ByVal lpValue As LongPtr) As Long + + Private HostRef As Host + + ' The tick count when Show was called, for DetachForm's "ready". + Public ShowTick As Long + + [DllExport] + Public Function tbCreateCompilerAddin(ByVal Host As Host) As AddIn + Set HostRef = Host + Return New DetachProbeAddin(Host) + End Function + + Public Sub Log(ByVal text As String) + HostRef.DebugConsole.PrintText "[DetachProbe] " & text + End Sub + + ' The harness starts the IDE with WEBVIEW2_USER_DATA_FOLDER and + ' WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS (its own page's WebView2 needs them), + ' and the compiler inherits them. The WebView2 loader lets both override + ' what a control asks for, so a form's control would share the IDE page's + ' folder and remote debugging port. A user's IDE has neither variable. + ' TB_DETACH_KEEPENV leaves them as they are. + Public Sub ClearWebView2Environment() + Dim names As Variant = Array("WEBVIEW2_USER_DATA_FOLDER", "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") + Dim i As Long + For i = 0 To 1 + Log "env " & names(i) & " = " & Environ$(names(i)) + If Len(Environ$("TB_DETACH_KEEPENV")) = 0 Then SetEnvironmentVariableW StrPtr(names(i)), 0 + Next + End Sub + + Public Function Ticks() As Long + Return GetTickCount() + End Function + + ' The form's window, as the probe sees it. + Public Sub DescribeWindow(ByVal hWnd As LongPtr) + Dim pid As Long + Dim r As RECT + GetWindowThreadProcessId hWnd, pid + GetWindowRect hWnd, r + Log "hwnd " & CLngPtr(hWnd) + Log "window pid " & pid & " current pid " & GetCurrentProcessId() + Log "visible " & IsWindowVisible(hWnd) + Log "rect " & r.Left & "," & r.Top & "," & r.Right & "," & r.Bottom + End Sub + + Public Function WindowVisible(ByVal hWnd As LongPtr) As Long + Return IsWindowVisible(hWnd) + End Function +End Module + +Private Class DetachProbeAddin + Implements AddIn + Private WithEvents Host As Host + Private WithEvents ShowButton As Button + Private WithEvents HideButton As Button + Private WithEvents ShowAgainButton As Button + Private WithEvents UnloadButton As Button + Private F As DetachForm + + Public Sub New(ByVal Host As Host) + Set Me.Host = Host + End Sub + + Private Property Get AddIn_Name() As String + Return "DetachProbe AddIn" + End Property + + Private Sub Host_OnProjectLoaded() + With Host.Toolbars(0) + Set ShowButton = .AddButton("detachProbe", "Detach probe") + Set HideButton = .AddButton("detachProbeHide", "Detach probe: hide") + Set ShowAgainButton = .AddButton("detachProbeShow", "Detach probe: show") + Set UnloadButton = .AddButton("detachProbeUnload", "Detach probe: unload") + End With + Log "loaded" + End Sub + + Private Sub ShowButton_OnClick() + ClearWebView2Environment + Set F = New DetachForm + ShowTick = Ticks() + F.Show vbModeless + Log "shown by Show" + DescribeWindow F.hWnd + End Sub + + Private Sub HideButton_OnClick() + If F Is Nothing Then Return + F.Hide + Log "hidden" + Log "visible " & WindowVisible(F.hWnd) + End Sub + + Private Sub ShowAgainButton_OnClick() + If F Is Nothing Then Return + F.Show vbModeless + Log "shown" + Log "visible " & WindowVisible(F.hWnd) + End Sub + + Private Sub UnloadButton_OnClick() + If F Is Nothing Then Return + Unload F + Set F = Nothing + Log "unloaded" + End Sub +End Class From 8c93fd59ae6173eb0acde15a3270eb909a3ca044 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 7 Oct 2026 15:10:47 +0200 Subject: [PATCH 32/33] The detached help window draws one title bar in the style of a tool window's header, its X hides it, and the IDE's Show real OS titlebar option gives it a dark or light Windows caption instead --- WIP.HelpAddin.md | 79 +++++++- add-in/Resources/SHELL/shell.css | 114 ++++++++++- add-in/Resources/SHELL/shell.html | 11 +- add-in/Resources/SHELL/shell.js | 68 ++++++- add-in/Sources/HelpAddIn.twin | 188 ++++++++++++++---- add-in/Sources/HelpWindow.tbform | 2 +- add-in/Sources/HelpWindow.twin | 34 +++- add-in/Sources/WindowApi.twin | 214 ++++++++++++++++++++- test/addin/help.test.mjs | 309 +++++++++++++++++++++++++++++- 9 files changed, 947 insertions(+), 72 deletions(-) diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index 6aac644ec..fa1aab58b 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -1049,9 +1049,61 @@ harness about a second against opening a new IDE. names `NewWindowRequested` and imports `ShellExecuteA`; not tried, since it might open a real browser); and the window holds the whole pane, not the page alone. - **Detach**, a button in the pane's bar, hides the tool window and shows `HelpWindow`; - **Attach** in the window, or its X (`QueryUnload` with `vbFormControlMenu` is - cancelled), hides the window and shows the pane. The window is made once and then - hidden and shown, so its page is kept. It has no owner window and shows in the taskbar. + **Attach** in the window's title bar hides the window and shows the pane. The window is + made once and then hidden and shown, so its page is kept. It has no owner window and + shows in the taskbar. + - **The title bar** (owner, 2026-10-07) is one row drawn by the page, in place of the + Windows caption, which is light even in the dark theme. It is the IDE's tool-window + header: `.sectionHeader`, with `--themePanelHeaderBackColor` and `...TextColor`, the + theme's `...Border*`, `...Padding` and `...FontSize`, weight 600, the 6-dot grip at the + left, 13px, and the pane's title, `TWINBASIC HELP`. The IDE's row is 20px high (its + close button, 16px with 2px margins), so the bar is 28px with the dark theme's padding + and 26px with the light theme's; the lane compares the bar's colours, font, padding and + height with the pane's own header. The grip and the glyphs are drawn with inline SVG in + the text colour, since the IDE's grip is an image, which is not copied. On the right are + Attach (a dock icon, "Attach to the IDE"), Minimize, Maximize or Restore and Close, + 24px by 20px, in `--themePanelCloseButtonForeColor`; the light theme's boxed close + button is not copied. + - **Dragging** the bar, grip included, moves the window: the page posts `drag` once the + left button is down and the mouse has moved 4px, or after the button has been held for + 250 ms (the IDE's own title bar holds for 250 ms before `DragFormStart`), and the + add-in calls `ReleaseCapture` and posts `WM_NCLBUTTONDOWN` with `HTCAPTION`, so that + Windows' own move loop runs, edge snapping included. A double click on the bar posts + `maxrestore`, which a drag that waits for movement does not pre-empt. The form's border + style is `vbSizableNoTitleBar`, a sizing frame with no caption, so the edges still + resize. Minimize, Maximize and Restore are `ShowWindow`; the add-in tells the page + `state:max`, `state:normal` or `state:min` from `Form_Resize` and when the page is + ready, and the page swaps the Maximize glyph. Windows maximizes a window with a sizing + frame and no title bar over the whole monitor, taskbar included (a page of 1707x1067 + against a work area of 1707x1027), so a window procedure put in front of the + form's (`SetWindowLongPtrW`, as `HelpServer` does for its message window) answers + `WM_GETMINMAXINFO` with the work area, kept outside the monitor by the frame's thickness + as Windows does for a window with a title bar, and the lane checks the page fills the + work area. The procedure is taken off in `Class_Terminate`. With the Windows title bar + it is not used. + - **The X** hides the window, as the pane's X hides the pane, and the help stays + detached; Attach is the way back to the pane. F1, the Help button and a hover link show + the window again. F1 and a hover link do not activate it, so the editor keeps the + focus: a hidden window is shown with `SW_SHOWNOACTIVATE`, and so is a minimized one; the + Help button activates it, as it focuses the search box. A window that has to be made + is activated, since it is shown with `Form.Show`. Alt+F4 and the system menu's Close + (`QueryUnload` with `vbFormControlMenu`, cancelled) hide it too. Whether it was shown is + kept (`Window`: `Shown`), so a hidden window stays hidden after a compiler restart and + is not made until the help is asked for. + - **Show real OS titlebar** (the IDE's option, `showRealTitlebar` in the JSON object of + the `GENERAL` value of `HKCU\Software\VB and VBA Program Settings\twinBASIC_IDE\IDESettings`, + default `false`; `main.js` passes it to `ChangeWindowSetting("RealTitlebar", ...)` and + hides its own title bar's elements) gives the window the Windows caption instead: the + border style is `vbSizable`, the caption text `twinBASIC Help`, the page draws no bar and + Attach is in the search bar again. The caption is dark with + `DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE)`, 20, falling back to 19, when + `Themes.ActiveThemeNameGroup` is `dark`, and light otherwise, and `SWP_FRAMECHANGED` + draws it again; a theme change applies it again. The add-in reads the option with + `RegGetValueW` each time the window is shown, and applies the frame then; the page is + told `chrome:custom` or `chrome:native`. Under the test switch only, + `TB_DOCS_HELP_REAL_TITLEBAR`, 1 or 0, decides in place of the registry, so a lane never + writes the IDE's settings. A form takes a new border style only when something else in + its frame is set, which setting the caption does (`BaseForm.twin`, `SyncTitlebarFlag`). - **Its page** is a shell (`Resources/SHELL/`) loaded with `NavigateToString`, with the pane's element ids, so `pane.css` applies unchanged; `shell.css` adds what the IDE's document gave the pane, and the results are plain `.hit` rows rather than the IDE's @@ -1068,17 +1120,28 @@ harness about a second against opening a new IDE. one `:root` rule; the lane checks four properties are the same in the IDE and the window, before and after a change of theme. Which folder the IDE prefers when both hold a file of one name was not found. - - **Kept** with `SaveSetting` (`Window`: `Detached`, `Left`, `Top`, `Width`, `Height`, - in pixels): a compiler restart ends the window with the compiler, and the new instance - opens it again. A saved place is used only if `MonitorFromRect` finds a monitor for it. - A show while the window is minimized restores it without activating it, so F1 does not - take the focus from the editor; otherwise the window is left where it is. + - **Kept** with `SaveSetting` (`Window`: `Detached`, `Shown`, `Left`, `Top`, `Width`, + `Height`, in pixels): a compiler restart ends the window with the compiler, and the new + instance opens it again unless it was hidden. A saved place is used only if + `MonitorFromRect` finds a monitor for it, and only a window that is neither minimized nor + maximized has a place to save. - **Its user data folder** is `%LOCALAPPDATA%\tbDocsHelp\WebView2`, and under the test switch `%TEMP%\tbDocsHelp`, which the lanes make private. Under the test switch only, the add-in also removes `WEBVIEW2_USER_DATA_FOLDER` and `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` from the compiler's environment (P19), and starts the control with the DevTools port in `TB_DOCS_HELP_WINDOW_PORT`, through which the lane operates the window's page. The `%LOCALAPPDATA%` path is not run by any lane. + - **The lanes** read the add-in's test-switch lines: `window shown`, `window hidden`, + `window state max|normal|min`, `window drag` and `window chrome custom|native style + `. They check the bar's one row and the four buttons, no `WS_CAPTION` + in the style, Maximize and Restore through the button and a double click, Minimize and + F1, a mouse press and move, and a press held, on the bar reaching the add-in, the X, + F1 after it, a hidden window staying hidden over a compiler restart, and the Windows + caption's style bits with `TB_DOCS_HELP_REAL_TITLEBAR=1`, for which the lane closes the + IDE and starts another, the variable being the IDE's environment. Not tested: the dark + caption (no read-back of the DWM attribute), Alt+F4, the registry read, maximizing on + a monitor other than the primary one (the lab machine has one), and the move + itself, whose loop needs the real mouse, which the harness's desktop has none of. 8. **Later:** offering only the packages the project references; how a user gets an add-in with the archive. diff --git a/add-in/Resources/SHELL/shell.css b/add-in/Resources/SHELL/shell.css index 572fa7d98..f26fbd86d 100644 --- a/add-in/Resources/SHELL/shell.css +++ b/add-in/Resources/SHELL/shell.css @@ -1,15 +1,21 @@ /* What the IDE's document gives the help pane and a page of its own does not: - the page's margin, height and font, and the results list, which here is a - plain element holding the same .hit rows the IDE's list view draws. The - colours are the IDE theme's custom properties, which the add-in sends as a - :root rule (ThemeCss). The plain palettes are for a theme it cannot find, - and :where gives them no weight, so the theme's rule wins whichever comes + the page's margin, height and font, the results list, which here is a + plain element holding the same .hit rows the IDE's list view draws, and the + title bar, drawn in the style of the IDE's tool-window header. The colours + are the IDE theme's custom properties, which the add-in sends as a :root + rule (ThemeCss). The plain palettes are for a theme it cannot find, and + :where gives them no weight, so the theme's rule wins whichever comes first. */ :where(:root[data-group="light"]) { --themeToolWindowBodyForeColor: rgb(44, 44, 44); --themeGeneralPanelBackColor: rgb(255, 255, 255); --themePropertySheetValueBorderColor: 1px solid rgb(213, 210, 210); --themeTreeItemHoverBackColor: rgb(219, 219, 219); + --themePanelHeaderBackColor: #bfcddb; + --themePanelHeaderTextColor: rgb(56, 56, 56); + --themePanelHeaderFontSize: 85%; + --themePanelHeaderBorderTop: 1px solid #f4f4f4; + --themePanelHeaderBorderLeft: 1px solid #a8a8a8; } :where(:root[data-group="dark"]) { @@ -17,6 +23,10 @@ --themeGeneralPanelBackColor: rgb(43, 43, 43); --themePropertySheetValueBorderColor: 1px solid rgb(96, 95, 95); --themeTreeItemHoverBackColor: rgb(113, 113, 113); + --themePanelHeaderBackColor: rgb(75, 67, 67); + --themePanelHeaderTextColor: rgb(236, 233, 233); + --themePanelHeaderFontSize: 100%; + --themePanelHeaderBorderTop: 1px solid rgb(100, 98, 98); } html, @@ -33,3 +43,97 @@ body { #helpResults { overflow-y: auto; } + +/* The title bar: one row, the IDE's tool-window header (.sectionHeader) with + its colours, borders, padding, font and grip, and the window's buttons where + the IDE has its close button. The IDE's row is 20px high, the height of its + close button with its margins. The whole bar, grip included, drags the + window (shell.js). With the Windows title bar (data-chrome="native") the bar + is not drawn, and Attach is in the search bar. */ +#helpTitleBar { + display: flex; + flex: 0 0 auto; + align-items: center; + box-sizing: border-box; + padding: var(--themePanelHeaderPadding, 2px 0 5px 0); + background-color: var(--themePanelHeaderBackColor); + color: var(--themePanelHeaderTextColor); + font-size: var(--themePanelHeaderFontSize, 100%); + font-weight: 600; + border-top: var(--themePanelHeaderBorderTop); + border-left: var(--themePanelHeaderBorderLeft); + border-right: var(--themePanelHeaderBorderRight); + border-bottom: var(--themePanelHeaderBorderBottom); + cursor: default; + -webkit-user-select: none; + user-select: none; +} + +html[data-chrome="native"] #helpTitleBar { + display: none; +} + +/* The grip is 13px, drawn in the title's colour at about the weight the IDE + gives its own, and 5px in from the edge; the title starts 7px after it. */ +#helpGrip { + flex: 0 0 auto; + width: 13px; + height: 13px; + margin: 0 2px 0 5px; + fill: currentColor; + opacity: 0.7; +} + +#helpTitle { + flex: 1 1 auto; + min-width: 0; + padding: 0 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#helpTitleBar .titleButton { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 24px; + height: 20px; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--themePanelCloseButtonForeColor, currentColor); +} + +#helpTitleBar .titleButton:hover { + background: color-mix(in srgb, currentColor 20%, transparent); +} + +.titleButton svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1; +} + +.titleButton svg .fill { + fill: currentColor; + stroke: none; +} + +/* Attach is an icon in the title bar and a word in the search bar. */ +#helpTitleBar #helpAttach .label, +#helpBar #helpAttach .dock { + display: none; +} + +/* The Maximize button shows Restore while the window is maximized. */ +html[data-state="max"] #helpMaximize .maximize, +html:not([data-state="max"]) #helpMaximize .restore { + display: none; +} diff --git a/add-in/Resources/SHELL/shell.html b/add-in/Resources/SHELL/shell.html index eef1ae928..7d943ac6b 100644 --- a/add-in/Resources/SHELL/shell.html +++ b/add-in/Resources/SHELL/shell.html @@ -1,5 +1,5 @@ - + twinBASIC Help @@ -9,10 +9,17 @@
+
+ +
TWINBASIC HELP
+ + + + +
-
diff --git a/add-in/Resources/SHELL/shell.js b/add-in/Resources/SHELL/shell.js index 3d1acd184..119c39992 100644 --- a/add-in/Resources/SHELL/shell.js +++ b/add-in/Resources/SHELL/shell.js @@ -3,22 +3,65 @@ // way is "verb:payload", split at the first colon. // // Page to add-in: ready, search:, enter, pick:, hover:<1|0>, -// attach, browser, frame: (the frame has loaded src). +// attach, browser, frame: (the frame has loaded src), and from the title +// bar drag (start moving the window), minimize, maxrestore, close. // Add-in to page: theme: a line break and , search:, // results:, page:, summary:, hover:<1|0>, hoverdisabled:<1|0>, -// browser:<1|0 enabled>, focus:. +// browser:<1|0 enabled>, focus:, chrome: (whether the page +// draws the title bar), state: (the window's). (function () { var host = window.chrome.webview; var byId = function (id) { return document.getElementById(id); }; + var root = document.documentElement; var search = byId("helpSearch"); var results = byId("helpResults"); var summary = byId("helpSummary"); var frame = byId("helpPage"); var browser = byId("helpBrowser"); var hover = byId("helpHover"); + var titleBar = byId("helpTitleBar"); + var attach = byId("helpAttach"); + var maximize = byId("helpMaximize"); function post(text) { host.postMessage(text); } + // A drag on the bar moves the window: it starts when the mouse has moved a + // few pixels with the left button down, or after the button has been held + // for 250 ms as in the IDE's own title bar. Until then a double click still + // arrives as one, and maximizes or restores the window. The system's move + // loop takes the mouse once it runs, so nothing here follows the drag. + var HOLD_MS = 250; + var MOVE_PX = 4; + var press = null; + + function endPress() { + if (press) clearTimeout(press.timer); + press = null; + } + + function startDrag() { + endPress(); + post("drag"); + } + + titleBar.addEventListener("mousedown", function (e) { + if (e.button !== 0 || e.target.closest("button")) return; + endPress(); + press = { x: e.clientX, y: e.clientY, timer: setTimeout(startDrag, HOLD_MS) }; + }); + document.addEventListener("mousemove", function (e) { + if (!press) return; + if ((e.buttons & 1) === 0) { endPress(); return; } + if (Math.hypot(e.clientX - press.x, e.clientY - press.y) > MOVE_PX) startDrag(); + }); + document.addEventListener("mouseup", endPress); + titleBar.addEventListener("dblclick", function (e) { + if (e.button === 0 && !e.target.closest("button")) post("maxrestore"); + }); + byId("helpMinimize").addEventListener("click", function () { post("minimize"); }); + maximize.addEventListener("click", function () { post("maxrestore"); }); + byId("helpClose").addEventListener("click", function () { post("close"); }); + search.addEventListener("input", function () { post("search:" + search.value); }); search.addEventListener("keyup", function (e) { if (e.key !== "Enter") return; @@ -31,7 +74,7 @@ }); hover.addEventListener("change", function () { post("hover:" + (hover.checked ? "1" : "0")); }); browser.addEventListener("click", function () { post("browser"); }); - byId("helpAttach").addEventListener("click", function () { post("attach"); }); + attach.addEventListener("click", function () { post("attach"); }); frame.addEventListener("load", function () { if (frame.getAttribute("src")) post("frame:" + frame.src); }); @@ -43,6 +86,23 @@ byId("theme").textContent = line < 0 ? "" : payload.slice(line + 1); } + // Where Attach is: a button in the title bar when the page draws one, a + // button in the search bar, before the Hover help box, when the window has + // the Windows title bar. + function setChrome(chrome) { + root.setAttribute("data-chrome", chrome); + if (chrome === "native") byId("helpBar").insertBefore(attach, byId("helpHoverLabel")); + else titleBar.insertBefore(attach, byId("helpMinimize")); + } + + // The window's state, which the Maximize button shows. + function setState(state) { + root.setAttribute("data-state", state); + var label = state === "max" ? "Restore" : "Maximize"; + maximize.title = label; + maximize.setAttribute("aria-label", label); + } + function showPage(url) { frame.src = url; frame.style.display = ""; @@ -75,6 +135,8 @@ case "hoverdisabled": hover.disabled = payload === "1"; break; case "browser": browser.disabled = payload !== "1"; break; case "focus": search.focus(); break; + case "chrome": setChrome(payload); break; + case "state": setState(payload); break; } }); diff --git a/add-in/Sources/HelpAddIn.twin b/add-in/Sources/HelpAddIn.twin index eeb30e969..490ee3a28 100644 --- a/add-in/Sources/HelpAddIn.twin +++ b/add-in/Sources/HelpAddIn.twin @@ -8,12 +8,20 @@ ' archive (HelpServer), and from the live site otherwise. The pane's Hover ' help box turns on links to the pages in the IDE's hover under the mouse ' (HoverHelp). The pane's Detach button moves the whole pane into a window of -' the add-in's own (HelpWindow), which can go to another monitor; Attach, or -' closing the window, brings it back. While it is detached everything that -' would show in the pane shows in the window, and the pane's frame is not -' loaded. The pane keeps the state either way: the window is another view of it. -' Every line it prints to the DEBUG CONSOLE starts "[tbDocsHelp] ", except -' "open " (see OpenUrl). +' the add-in's own (HelpWindow), which can go to another monitor; the window's +' Attach button brings it back. While it is detached everything that would show +' in the pane shows in the window, and the pane's frame is not loaded. The pane +' keeps the state either way: the window is another view of it. The window's +' title bar is one row drawn in its page, in the style of a tool window's +' header, with Attach, Minimize, Maximize and Close in it; its X hides the +' window, as the pane's X hides the pane, and the help stays detached until F1, +' the Help button or a hover link shows the window again (without activating +' it, except for the Help button). Whether the window was shown is kept, so a +' hidden window stays hidden after a compiler restart. When the IDE's option +' "Show real OS titlebar" is on, the window has the Windows title bar instead, +' dark or light with the theme, and the page draws none; the option is read +' each time the window is shown. Every line it prints to the DEBUG CONSOLE +' starts "[tbDocsHelp] ", except "open " (see OpenUrl). Private Class HelpAddIn Implements AddIn Private WithEvents Host As Host @@ -39,6 +47,9 @@ Private Class HelpAddIn Private DetachButton As HtmlElement Private WithEvents HelpWin As HelpWindow ' Nothing until the first Detach, then kept Private Detached As Boolean ' the window shows the help, the pane is hidden + Private WindowShown As Boolean ' Detached, and the window is not hidden + Private NativeFrame As Boolean ' the window has the Windows title bar + Private FocusOnReady As Boolean ' the search box gets the focus when the page is ready Private LoadedFrame As String ' the src the pane's frame was given Private SearchText As String ' what the search box holds, in either view Private HoverOn As Boolean ' what the Hover help box holds, in either view @@ -52,7 +63,8 @@ Private Class HelpAddIn ' SaveSetting's application name; test/addin/lanes.mjs names it too. Private Const SETTINGS_APP As String = "tbDocsHelp" Private Const SEARCH_LIMIT As Long = 100 - ' The window's settings: whether it is detached, and where it is, in pixels. + ' The window's settings: whether it is detached, whether it is shown, and + ' where it is, in pixels. Private Const WINDOW_SECTION As String = "Window" Public Sub New(ByVal Host As Host) @@ -72,14 +84,18 @@ Private Class HelpAddIn If Not Index Is Nothing Then Set Hovering = New HoverHelp(Host, Index, BASE_URL) StartServer PageUrl = GetSetting(SETTINGS_APP, "Pane", "Page", "") + ' Known before the pane is built, so that its frame is not loaded for + ' a help that is in the window. + Detached = GetSetting(SETTINGS_APP, WINDOW_SECTION, "Detached", "0") = "1" BuildPane ' The scenarios wait for this line, and check the switch in it before ' pressing anything. Host.DebugConsole.PrintText TAG & "loaded, " & indexState & ", test switch " & _ IIf(TestSwitchOn(), "on", "off") ' A compiler restart ends the window with the compiler: this instance - ' opens it again. - If GetSetting(SETTINGS_APP, WINDOW_SECTION, "Detached", "0") = "1" Then Detach + ' opens it again, unless it was hidden, which it stays until the help is + ' asked for (ShowView). + If Detached And GetSetting(SETTINGS_APP, WINDOW_SECTION, "Shown", "1") = "1" Then Detach End Sub Private Function LoadIndex() As String @@ -104,9 +120,10 @@ Private Class HelpAddIn End Sub ' The window goes with the compiler, so its place is saved here as well as - ' when it is attached or closed. + ' when it is attached or closed, and the window's own procedure put back. Private Sub Class_Terminate() If Not HelpWin Is Nothing Then SaveWindowPlace + EndMaximizeLimit If Not Server Is Nothing Then Server.CloseAll End Sub @@ -145,13 +162,15 @@ Private Class HelpAddIn End Function ' A theme change loads the page again with the new theme: in the pane's - ' frame, or in the window, which is also sent the theme's properties. The - ' pane itself follows the theme through the IDE's CSS variables (pane.css). + ' frame, or in the window, which is also sent the theme's properties and, + ' with the Windows title bar, has it made dark or light. The pane itself + ' follows the theme through the IDE's CSS variables (pane.css). Private Sub Host_OnChangedTheme(ByVal ThemeName As String) On Error GoTo Failed If Detached Then - HelpWin.Post "theme:" & ThemeMessage() - If Len(PageUrl) > 0 And Not ShowingSummary Then HelpWin.Post "page:" & FrameUrl(PageUrl) + PostToWindow "theme:" & ThemeMessage() + If Len(PageUrl) > 0 And Not ShowingSummary Then PostToWindow "page:" & FrameUrl(PageUrl) + If Not HelpWin Is Nothing Then ApplyTitleBarTheme ElseIf Len(PageUrl) > 0 And Not Frame Is Nothing Then LoadedFrame = FrameUrl(PageUrl) Frame.Properties.src = LoadedFrame @@ -251,7 +270,7 @@ Private Class HelpAddIn SaveSetting SETTINGS_APP, "Hover", "On", IIf(isOn, "1", "0") Hovering.Enabled = isOn HoverBox.Properties.checked = isOn - If Detached Then HelpWin.Post "hover:" & IIf(isOn, "1", "0") + If Detached Then PostToWindow "hover:" & IIf(isOn, "1", "0") End Sub Private Sub Hovering_Pick(ByVal url As String) @@ -270,23 +289,29 @@ Private Class HelpAddIn ShowPaneForSearch End Sub + ' Shows the view that has the help, and puts the focus in its search box. Private Sub ShowPaneForSearch() - ShowView - If Detached Then + ShowView True + If Not Detached Then + SearchBox.Properties.focus() + ElseIf HelpWin.IsReady Then HelpWin.Post "focus:" Else - SearchBox.Properties.focus() + FocusOnReady = True End If End Sub ' Shows the view that has the help: the pane, or the detached window, which - ' is left where it is unless it is minimized. Then it is restored without - ' taking the focus from the editor the user is typing in. - Private Sub ShowView() + ' is left where it is unless it is hidden or minimized. Then it is shown + ' without taking the focus from the editor the user is typing in, unless + ' activate says the user asked for the window itself. + Private Sub ShowView(Optional ByVal activate As Boolean = False) If Not Detached Then Pane.Visible = True + ElseIf Not WindowShown Then + OpenWindow activate ElseIf IsMinimized(HelpWin.hWnd) Then - ShowWithoutActivating HelpWin.hWnd + If activate Then RestoreWindow HelpWin.hWnd Else ShowWithoutActivating HelpWin.hWnd End If End Sub @@ -362,7 +387,7 @@ Private Class HelpAddIn Private Sub SetSearchText(ByVal text As String) SearchText = text SearchBox.Properties.value = text - If Detached Then HelpWin.Post "search:" & text + If Detached Then PostToWindow "search:" & text End Sub ' Shows a page in the pane's frame, or in the window's, in place of a @@ -375,8 +400,8 @@ Private Class HelpAddIn Frame.Properties.style.display = "" BrowserButton.Properties.disabled = False If Detached Then - HelpWin.Post "page:" & FrameUrl(url) - HelpWin.Post "browser:1" + PostToWindow "page:" & FrameUrl(url) + PostToWindow "browser:1" Else LoadedFrame = FrameUrl(url) Frame.Properties.src = LoadedFrame @@ -402,8 +427,8 @@ Private Class HelpAddIn Summary.Properties.style.display = "" BrowserButton.Properties.disabled = True If Detached Then - HelpWin.Post "summary:" & SummaryHtml - HelpWin.Post "browser:0" + PostToWindow "summary:" & SummaryHtml + PostToWindow "browser:0" If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window summary" End If ShowView @@ -441,13 +466,14 @@ Private Class HelpAddIn ' carry the entry's number for it to post back; nothing else of the text ' is trusted. Private Sub PostResults() + If HelpWin Is Nothing Then Exit Sub Dim html As String Dim i As Variant For Each i In ResultIds html = html & "
" & ResultText(i) & "
" Next If ResultIds.Count = 0 And Len(LastSearch) > 0 Then html = "
No matches
" - HelpWin.Post "results:" & html + PostToWindow "results:" & html If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window results " & ResultIds.Count End Sub @@ -528,12 +554,9 @@ Private Class HelpAddIn ' now. Private Sub Detach() On Error GoTo Failed - Dim first As Boolean = HelpWin Is Nothing - If first Then Set HelpWin = New HelpWindow Detached = True Pane.Visible = False - HelpWin.Show vbModeless - If first Then RestoreWindowPlace + OpenWindow True SaveSetting SETTINGS_APP, WINDOW_SECTION, "Detached", "1" If HelpWin.IsReady Then SendWindowState If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "detached" @@ -545,12 +568,83 @@ Private Class HelpAddIn Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub + ' Shows the window, made the first time, in the frame the IDE's option asks + ' for. A window that is made, or that the user asked for, is activated; + ' another is shown without it, so that F1 does not take the focus from the + ' editor. The place it was left at is used the first time only. + Private Sub OpenWindow(ByVal activate As Boolean) + Dim first As Boolean = HelpWin Is Nothing + If first Then Set HelpWin = New HelpWindow + ApplyFrame + If first Or activate Then + HelpWin.Show vbModeless + If IsMinimized(HelpWin.hWnd) Then RestoreWindow HelpWin.hWnd + Else + ShowWithoutActivating HelpWin.hWnd + End If + If first Then RestoreWindowPlace + WindowShown = True + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Shown", "1" + If TestSwitchOn() Then + Host.DebugConsole.PrintText TAG & "window shown" + Host.DebugConsole.PrintText TAG & "window chrome " & IIf(NativeFrame, "native", "custom") & _ + " style " & Hex$(WindowStyle(HelpWin.hWnd)) + End If + End Sub + + ' Hides the window and leaves the help detached: F1, the Help button or a + ' hover link shows it again. The page is kept. + Private Sub HideWindow() + On Error GoTo Failed + SaveWindowPlace + HelpWin.Hide + WindowShown = False + SaveSetting SETTINGS_APP, WINDOW_SECTION, "Shown", "0" + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window hidden" + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Whether the window has the Windows title bar: the IDE's option "Show real + ' OS titlebar". Under the test switch TB_DOCS_HELP_REAL_TITLEBAR, 1 or 0, + ' decides instead, so that a test never changes the IDE's own options. + Private Function WantsNativeFrame() As Boolean + If TestSwitchOn() Then + Dim forced As String = Environ$("TB_DOCS_HELP_REAL_TITLEBAR") + If Len(forced) > 0 Then Return forced = "1" + End If + Return ShowsRealTitlebar() + End Function + + ' Gives the window the frame the option asks for, and tells the page, which + ' draws its own title bar for the frame that has none. + Private Sub ApplyFrame() + NativeFrame = WantsNativeFrame() + HelpWin.UseNativeFrame NativeFrame + LimitMaximizeToWorkArea HelpWin.hWnd, Not NativeFrame + ApplyTitleBarTheme + PostToWindow "chrome:" & IIf(NativeFrame, "native", "custom") + End Sub + + ' Makes the Windows title bar dark with a dark IDE theme, light otherwise. + ' The page draws its own in the theme's colours. + Private Sub ApplyTitleBarTheme() + If NativeFrame Then SetDarkTitleBar HelpWin.hWnd, Host.Themes.ActiveThemeNameGroup = "dark" + End Sub + + ' Sends the page a message, if the window has been made. + Private Sub PostToWindow(ByVal text As String) + If Not HelpWin Is Nothing Then HelpWin.Post text + End Sub + ' Hides the window and shows the pane again, with the search box as it was ' left in the window and the page the pane's frame has not been given yet. Private Sub Attach() On Error GoTo Failed SaveWindowPlace HelpWin.Hide + WindowShown = False Detached = False SaveSetting SETTINGS_APP, WINDOW_SECTION, "Detached", "0" SearchBox.Properties.value = SearchText @@ -567,9 +661,15 @@ Private Class HelpAddIn Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub - ' The X asks to close the window, which is Attach. + ' Alt+F4 and the system menu's Close hide the window, as the X does. Private Sub HelpWin_Closing() - Attach + HideWindow + End Sub + + ' The window was minimized, maximized or restored. + Private Sub HelpWin_StateChanged(ByVal state As String) + PostToWindow "state:" & state + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window state " & state End Sub Private Sub HelpWin_Failed(ByVal text As String) @@ -599,6 +699,15 @@ Private Class HelpAddIn SetHover payload = "1" Case "attach" Attach + Case "close" + HideWindow + Case "minimize" + MinimizeWindow HelpWin.hWnd + Case "maxrestore" + MaximizeOrRestore HelpWin.hWnd + Case "drag" + If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window drag" + StartWindowDrag HelpWin.hWnd Case "browser" OpenInBrowser Case "frame" @@ -609,9 +718,12 @@ Private Class HelpAddIn Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description End Sub - ' Gives the window all that the pane shows: the theme, the search box, the - ' Hover help box, the results, and the page or the summary. + ' Gives the window all that the pane shows: the frame it has, its state, + ' the theme, the search box, the Hover help box, the results, and the page + ' or the summary. Private Sub SendWindowState() + HelpWin.Post "chrome:" & IIf(NativeFrame, "native", "custom") + HelpWin.Post "state:" & StateOfWindow(HelpWin.hWnd) HelpWin.Post "theme:" & ThemeMessage() HelpWin.Post "hoverdisabled:" & IIf(Hovering Is Nothing, "1", "0") HelpWin.Post "hover:" & IIf(HoverOn, "1", "0") @@ -624,6 +736,10 @@ Private Class HelpAddIn If Len(PageUrl) > 0 Then HelpWin.Post "page:" & FrameUrl(PageUrl) HelpWin.Post "browser:1" End If + If FocusOnReady Then + FocusOnReady = False + HelpWin.Post "focus:" + End If End Sub ' The window's place is kept from one session to the next: its position diff --git a/add-in/Sources/HelpWindow.tbform b/add-in/Sources/HelpWindow.tbform index 40c4f79c3..be5e64745 100644 --- a/add-in/Sources/HelpWindow.tbform +++ b/add-in/Sources/HelpWindow.tbform @@ -1,7 +1,7 @@ [ { "Appearance": "vbAppear3d", - "BorderStyle": "vbSizable", + "BorderStyle": "vbSizableNoTitleBar", "Caption": "twinBASIC Help", "ControlBox": true, "Enabled": true, diff --git a/add-in/Sources/HelpWindow.twin b/add-in/Sources/HelpWindow.twin index 5bc3d29a9..28cc26038 100644 --- a/add-in/Sources/HelpWindow.twin +++ b/add-in/Sources/HelpWindow.twin @@ -1,16 +1,25 @@ ' The detached help window: a top-level window of the add-in's own, with one ' WebView2 on it, which shows the whole help pane (Resources/SHELL). It knows ' nothing of the help itself. What the page posts comes out as Message, what the -' add-in posts goes in with Post, and closing it with the X raises Closing and -' leaves the window to the add-in, which hides it. It has no owner window, so -' the user can move it to another monitor and the IDE does not cover it. +' add-in posts goes in with Post, and closing it with Alt+F4 or the system menu +' raises Closing and leaves the window to the add-in, which hides it. It has no +' owner window, so the user can move it to another monitor and the IDE does not +' cover it. +' +' The window has a frame with no title bar (vbSizableNoTitleBar), since the page +' draws a title bar in the IDE's style, one row with the window's buttons in it; +' UseNativeFrame gives it the Windows title bar instead, for an IDE whose option +' "Show real OS titlebar" is on. StateChanged says when the window is minimized, +' maximized or restored, which the page shows on its Maximize button. [FormDesignerId("4581D804-116B-436E-B7AB-1E4417F9703D")] Class HelpWindow Public Event Message(ByVal text As String) Public Event Closing() + Public Event StateChanged(ByVal state As String) Public Event Failed(ByVal text As String) Private PageReady As Boolean + Private LastState As String Public Sub New() End Sub @@ -75,7 +84,24 @@ Class HelpWindow RaiseEvent Message(text) End Sub - ' The X, and the window menu's Close, only ask to close: the window is + ' Gives the window the Windows title bar, which the page then does not + ' draw, or the frame with none. A form takes a new border style only when + ' something else about its frame is set, and setting the caption does that. + Public Sub UseNativeFrame(ByVal native As Boolean) + BorderStyle = IIf(native, FormBorderStyleConstants.vbSizable, FormBorderStyleConstants.vbSizableNoTitleBar) + Caption = "twinBASIC Help" + End Sub + + ' A resize also comes from a minimize, a maximize and a restore, and is + ' told as the window's state when that is what changed. + Private Sub Form_Resize() + Dim state As String = StateOfWindow(hWnd) + If state = LastState Then Exit Sub + LastState = state + RaiseEvent StateChanged(state) + End Sub + + ' The window menu's Close and Alt+F4 only ask to close: the window is ' hidden and shown again, so that its page is kept. Anything else, as the ' add-in unloading it, goes through. Private Sub Form_QueryUnload(Cancel As Integer, UnloadMode As Integer) diff --git a/add-in/Sources/WindowApi.twin b/add-in/Sources/WindowApi.twin index 4314fa98c..6c1f164ec 100644 --- a/add-in/Sources/WindowApi.twin +++ b/add-in/Sources/WindowApi.twin @@ -1,6 +1,7 @@ ' The Win32 calls the detached help window needs: where the compiler is, the -' window's place on the screen, showing it without taking the focus, and the -' compiler's environment, so that no other file declares a Win32 function. +' window's place, state and frame, the move loop its title bar starts, the IDE's +' "Show real OS titlebar" setting, and the compiler's environment, so that no +' other file declares a Win32 function. Module WindowApi Private Type RECT Left As Long @@ -9,20 +10,90 @@ Module WindowApi Bottom As Long End Type + Private Type POINTAPI + X As Long + Y As Long + End Type + + Private Type MINMAXINFO + ptReserved As POINTAPI + ptMaxSize As POINTAPI + ptMaxPosition As POINTAPI + ptMinTrackSize As POINTAPI + ptMaxTrackSize As POINTAPI + End Type + + Private Type MONITORINFO + cbSize As Long + rcMonitor As RECT + rcWork As RECT + dwFlags As Long + End Type + Private Declare PtrSafe Function GetModuleFileNameW Lib "kernel32" (ByVal hModule As LongPtr, _ ByVal lpFilename As LongPtr, ByVal nSize As Long) As Long Private Declare PtrSafe Function SetEnvironmentVariableW Lib "kernel32" (ByVal lpName As LongPtr, _ ByVal lpValue As LongPtr) As Long Private Declare PtrSafe Function IsIconic Lib "user32" (ByVal hWnd As LongPtr) As Long + Private Declare PtrSafe Function IsZoomed Lib "user32" (ByVal hWnd As LongPtr) As Long Private Declare PtrSafe Function ShowWindow Lib "user32" (ByVal hWnd As LongPtr, ByVal nCmdShow As Long) As Long Private Declare PtrSafe Function GetWindowRect Lib "user32" (ByVal hWnd As LongPtr, ByRef lpRect As RECT) As Long Private Declare PtrSafe Function MoveWindow Lib "user32" (ByVal hWnd As LongPtr, ByVal X As Long, _ ByVal Y As Long, ByVal nWidth As Long, ByVal nHeight As Long, ByVal bRepaint As Long) As Long Private Declare PtrSafe Function MonitorFromRect Lib "user32" (ByRef lprc As RECT, ByVal dwFlags As Long) As LongPtr + Private Declare PtrSafe Function GetWindowLongW Lib "user32" (ByVal hWnd As LongPtr, ByVal nIndex As Long) As Long + Private Declare PtrSafe Function SetWindowPos Lib "user32" (ByVal hWnd As LongPtr, ByVal hWndInsertAfter As LongPtr, _ + ByVal X As Long, ByVal Y As Long, ByVal cx As Long, ByVal cy As Long, ByVal uFlags As Long) As Long + Private Declare PtrSafe Function ReleaseCapture Lib "user32" () As Long + Private Declare PtrSafe Function PostMessageW Lib "user32" (ByVal hWnd As LongPtr, ByVal Msg As Long, _ + ByVal wParam As LongPtr, ByVal lParam As LongPtr) As Long + Private Declare PtrSafe Function MonitorFromWindow Lib "user32" (ByVal hWnd As LongPtr, ByVal dwFlags As Long) As LongPtr + Private Declare PtrSafe Function GetMonitorInfoW Lib "user32" (ByVal hMonitor As LongPtr, ByRef lpmi As MONITORINFO) As Long + Private Declare PtrSafe Function CallWindowProcW Lib "user32" (ByVal lpPrevWndFunc As LongPtr, ByVal hWnd As LongPtr, _ + ByVal uMsg As Long, ByVal wParam As LongPtr, ByVal lParam As LongPtr) As LongPtr + #If Win64 Then + Private Declare PtrSafe Function SetWindowProc Lib "user32" Alias "SetWindowLongPtrW" (ByVal hWnd As LongPtr, _ + ByVal nIndex As Long, ByVal dwNewLong As LongPtr) As LongPtr + #Else + Private Declare PtrSafe Function SetWindowProc Lib "user32" Alias "SetWindowLongW" (ByVal hWnd As LongPtr, _ + ByVal nIndex As Long, ByVal dwNewLong As LongPtr) As LongPtr + #End If + Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef Destination As Any, _ + ByRef Source As Any, ByVal Length As LongPtr) + Private Declare PtrSafe Function DwmSetWindowAttribute Lib "dwmapi" (ByVal hWnd As LongPtr, _ + ByVal dwAttribute As Long, ByRef pvAttribute As Long, ByVal cbAttribute As Long) As Long + Private Declare PtrSafe Function RegGetValueW Lib "advapi32" (ByVal hKey As LongPtr, ByVal lpSubKey As LongPtr, _ + ByVal lpValue As LongPtr, ByVal dwFlags As Long, ByVal pdwType As LongPtr, ByVal pvData As LongPtr, _ + ByRef pcbData As Long) As Long + + Private PreviousProc As LongPtr ' the window's own procedure, while ours is in front of it + Private OverriddenWindow As LongPtr + Private LimitMaximize As Boolean Private Const MAX_PATH As Long = 520 + Private Const SW_MINIMIZE As Long = 6 + Private Const SW_MAXIMIZE As Long = 3 + Private Const SW_RESTORE As Long = 9 Private Const SW_SHOWNOACTIVATE As Long = 4 Private Const MONITOR_DEFAULTTONULL As Long = 0 + Private Const GWL_STYLE As Long = -16 + Private Const SWP_NOSIZE As Long = &H1 + Private Const SWP_NOMOVE As Long = &H2 + Private Const SWP_NOZORDER As Long = &H4 + Private Const SWP_NOACTIVATE As Long = &H10 + Private Const SWP_FRAMECHANGED As Long = &H20 + Private Const MONITOR_DEFAULTTONEAREST As Long = 2 + Private Const GWLP_WNDPROC As Long = -4 + Private Const WM_GETMINMAXINFO As Long = &H24 + Private Const WM_NCLBUTTONDOWN As Long = &HA1 + Private Const HTCAPTION As Long = 2 + ' DWMWA_USE_IMMERSIVE_DARK_MODE: 20 on Windows 10 2004 and later, 19 before. + Private Const DWMWA_USE_IMMERSIVE_DARK_MODE As Long = 20 + Private Const DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 As Long = 19 + Private Const HKEY_CURRENT_USER As Long = &H80000001 + Private Const RRF_RT_REG_SZ As Long = &H2 + ' Where the IDE keeps its options: one value, GENERAL, a JSON object. + Private Const IDE_SETTINGS_KEY As String = "Software\VB and VBA Program Settings\twinBASIC_IDE\IDESettings" ' The folder of the twinBASIC install, the parent of the bin folder the ' compiler's executable is in. The add-in runs in the compiler's process. @@ -48,17 +119,40 @@ Module WindowApi Return IsIconic(hWnd) <> 0 End Function - ' Shows a window in its last size and place, as it was before it was - ' minimized, without activating it. + ' "min", "max" or "normal". + Public Function StateOfWindow(ByVal hWnd As LongPtr) As String + If IsIconic(hWnd) <> 0 Then Return "min" + If IsZoomed(hWnd) <> 0 Then Return "max" + Return "normal" + End Function + + Public Sub MinimizeWindow(ByVal hWnd As LongPtr) + ShowWindow hWnd, SW_MINIMIZE + End Sub + + ' The maximize button: a maximized window goes back to its size, any other + ' is maximized. + Public Sub MaximizeOrRestore(ByVal hWnd As LongPtr) + ShowWindow hWnd, IIf(IsZoomed(hWnd) <> 0, SW_RESTORE, SW_MAXIMIZE) + End Sub + + ' Brings a minimized window back, which activates it. + Public Sub RestoreWindow(ByVal hWnd As LongPtr) + ShowWindow hWnd, SW_RESTORE + End Sub + + ' Shows a window in its last size and place, as it was before it was hidden + ' or minimized, without activating it. Public Sub ShowWithoutActivating(ByVal hWnd As LongPtr) ShowWindow hWnd, SW_SHOWNOACTIVATE End Sub ' The window's rectangle on the screen, in pixels. False when the window - ' has none, or is minimized, whose rectangle is not a place. + ' has none, or is minimized or maximized, whose rectangle is not the place + ' it goes back to. Public Function WindowPlace(ByVal hWnd As LongPtr, ByRef Left As Long, ByRef Top As Long, _ ByRef Width As Long, ByRef Height As Long) As Boolean - If IsIconic(hWnd) <> 0 Then Return False + If IsIconic(hWnd) <> 0 Or IsZoomed(hWnd) <> 0 Then Return False Dim r As RECT If GetWindowRect(hWnd, r) = 0 Then Return False Left = r.Left @@ -84,4 +178,112 @@ Module WindowApi r.Bottom = Top + Height Return MonitorFromRect(r, MONITOR_DEFAULTTONULL) <> 0 End Function + + ' The window's style bits (GWL_STYLE). + Public Function WindowStyle(ByVal hWnd As LongPtr) As Long + Return GetWindowLongW(hWnd, GWL_STYLE) + End Function + + ' Starts the move loop Windows runs for a drag on a title bar, so that a + ' window with no title bar of its own is moved by one drawn in its page, + ' and snaps at the screen's edges. The page's control may hold the mouse, + ' which the loop needs for itself. The message is posted, so that the loop + ' runs from the window's own message loop and not inside the page's message. + Public Sub StartWindowDrag(ByVal hWnd As LongPtr) + ReleaseCapture + PostMessageW hWnd, WM_NCLBUTTONDOWN, HTCAPTION, 0 + End Sub + + ' Makes the Windows title bar dark or light, and draws the frame again so + ' that it shows. The attribute is 20 from Windows 10 2004 on, and 19 before. + Public Sub SetDarkTitleBar(ByVal hWnd As LongPtr, ByVal dark As Boolean) + Dim value As Long = IIf(dark, 1, 0) + If DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, value, 4) <> 0 Then + DwmSetWindowAttribute hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, value, 4 + End If + SetWindowPos hWnd, 0, 0, 0, 0, 0, SWP_NOMOVE Or SWP_NOSIZE Or SWP_NOZORDER Or SWP_NOACTIVATE Or SWP_FRAMECHANGED + End Sub + + ' Windows maximizes a window that has a sizing frame and no title bar over + ' the whole monitor, taskbar included. With limit set, a window procedure + ' put in front of the window's own answers WM_GETMINMAXINFO with the + ' monitor's work area, so that it is maximized as one with a title bar is. + ' The procedure is put in front once, and stays until EndMaximizeLimit. + Public Sub LimitMaximizeToWorkArea(ByVal hWnd As LongPtr, ByVal limit As Boolean) + LimitMaximize = limit + If limit And OverriddenWindow = 0 Then + PreviousProc = SetWindowProc(hWnd, GWLP_WNDPROC, AddressOf MaximizeProc) + OverriddenWindow = hWnd + End If + End Sub + + ' Puts the window's own procedure back, for the add-in to unload with. + Public Sub EndMaximizeLimit() + If OverriddenWindow <> 0 Then SetWindowProc OverriddenWindow, GWLP_WNDPROC, PreviousProc + OverriddenWindow = 0 + End Sub + + ' The procedure LimitMaximizeToWorkArea puts in front: the window's own + ' answer to WM_GETMINMAXINFO, with the maximized position and size changed + ' to the work area of the monitor the window is on (the position is + ' relative to the monitor), and everything else as it is. + Public Function MaximizeProc(ByVal hWnd As LongPtr, ByVal uMsg As Long, ByVal wParam As LongPtr, _ + ByVal lParam As LongPtr) As LongPtr + MaximizeProc = CallWindowProcW(PreviousProc, hWnd, uMsg, wParam, lParam) + If uMsg <> WM_GETMINMAXINFO Or Not LimitMaximize Then Exit Function + Dim monitor As MONITORINFO + monitor.cbSize = Len(monitor) + If GetMonitorInfoW(MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), monitor) = 0 Then Exit Function + Dim info As MINMAXINFO + CopyMemory info, ByVal lParam, Len(info) + ' A maximized window with a sizing frame has the frame's thickness + ' outside the work area on each side, which is how far the window's own + ' answer puts it outside the monitor. + Dim frame As Long = IIf(info.ptMaxPosition.X < 0, -info.ptMaxPosition.X, 0) + info.ptMaxPosition.X = monitor.rcWork.Left - monitor.rcMonitor.Left - frame + info.ptMaxPosition.Y = monitor.rcWork.Top - monitor.rcMonitor.Top - frame + info.ptMaxSize.X = monitor.rcWork.Right - monitor.rcWork.Left + 2 * frame + info.ptMaxSize.Y = monitor.rcWork.Bottom - monitor.rcWork.Top + 2 * frame + CopyMemory ByVal lParam, info, Len(info) + End Function + + ' The IDE's option "IDE: Show real OS titlebar", showRealTitlebar in the + ' JSON object its GENERAL value holds. False when the value or the property + ' is not there, which is the option's default. + Public Function ShowsRealTitlebar() As Boolean + Dim json As String = ReadIdeOptions() + Dim at As Long = InStr(json, """showRealTitlebar""") + If at = 0 Then Return False + Dim i As Long = at + Len("""showRealTitlebar""") + i = SkipWhite(json, i) + If Mid$(json, i, 1) <> ":" Then Return False + i = SkipWhite(json, i + 1) + Return Mid$(json, i, 4) = "true" + End Function + + ' The position of the first character at or after i that is not white space. + Private Function SkipWhite(ByVal text As String, ByVal i As Long) As Long + Do While i <= Len(text) + Select Case Mid$(text, i, 1) + Case " ", vbTab, vbCr, vbLf + i = i + 1 + Case Else + Exit Do + End Select + Loop + Return i + End Function + + ' The text of the IDE's GENERAL option value, or "" if the IDE has not + ' saved it. + Private Function ReadIdeOptions() As String + Dim size As Long + If RegGetValueW(HKEY_CURRENT_USER, StrPtr(IDE_SETTINGS_KEY), StrPtr("GENERAL"), RRF_RT_REG_SZ, 0, 0, size) <> 0 Then + Return "" + End If + Dim buffer As String = String$(size \ 2 + 1, vbNullChar) + If RegGetValueW(HKEY_CURRENT_USER, StrPtr(IDE_SETTINGS_KEY), StrPtr("GENERAL"), RRF_RT_REG_SZ, 0, _ + StrPtr(buffer), size) <> 0 Then Return "" + Return Left$(buffer, InStr(buffer, vbNullChar) - 1) + End Function End Module diff --git a/test/addin/help.test.mjs b/test/addin/help.test.mjs index 2a4743d85..257c430eb 100644 --- a/test/addin/help.test.mjs +++ b/test/addin/help.test.mjs @@ -158,6 +158,15 @@ scenario("the help add-in: F1 and the help pane", (lane) => { let server; let windowPort; // the detached window's DevTools port const offline = lane.name === "help-offline"; + // What the IDE is started with: the site this file serves, the window's + // debugging port, and the window's frame, which the IDE's own option + // "Show real OS titlebar" would otherwise decide: 0 the frame the page draws + // a title bar for, 1 the Windows one. The option is never changed. + const ideEnv = (realTitlebar) => ({ + TB_DOCS_HELP_SITE: origin, + TB_DOCS_HELP_WINDOW_PORT: String(windowPort), + TB_DOCS_HELP_REAL_TITLEBAR: String(realTitlebar), + }); // The frame shows `url` from the built site in the IDE's theme: the add-in // gave it that src with ?theme= and ?pane=1, the page it loaded is the @@ -287,7 +296,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { } await lane.addAddin(src); [windowPort] = await claimPorts(1, { from: 9760 }); - c = await lane.open(HOST, { env: { TB_DOCS_HELP_SITE: origin, TB_DOCS_HELP_WINDOW_PORT: String(windowPort) } }); + c = await lane.open(HOST, { env: ideEnv(0) }); pages = await pagesOrigin(null); await openFile(c, FILE, { line: 5, column: 9 }); }); @@ -474,8 +483,9 @@ scenario("the help add-in: F1 and the help pane", (lane) => { await waitFor(c, async (c) => (await results(c)).join() === "No matches"), JSON.stringify(await results(c)), ); - const box = await resultsBox(c); - assert.ok(box?.row > 0 && Math.abs(box.height - box.row) < 1 && !box.scrollbar, JSON.stringify(box)); + // The list draws its rows a moment after it has them: no row has a height yet. + const box = await waitFor(c, async (c) => (await resultsBox(c))?.row > 0 && (await resultsBox(c))); + assert.ok(box && Math.abs(box.height - box.row) < 1 && !box.scrollbar, JSON.stringify(await resultsBox(c))); }); test("the results list is as tall as its rows, up to 35% of the pane", async () => { @@ -484,7 +494,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { // The add-in fills the list before it shows it, so wait for both. const few = await waitFor(c, async (c) => { const r = await results(c); - return r.length > 0 && r[0] === "Interaction.MsgBoxfunction" && (await resultsBox(c)) && r.length; + return r.length > 0 && r[0] === "Interaction.MsgBoxfunction" && (await resultsBox(c))?.row > 0 && r.length; }); let box = await resultsBox(c); assert.ok( @@ -518,6 +528,24 @@ scenario("the help add-in: F1 and the help pane", (lane) => { assert.equal(await waitFor(c, async (c) => (await focusedId(c)) === "helpSearch" && "helpSearch"), "helpSearch"); }); + // The IDE draws the pane's header, the tool-window header the detached + // window's title bar copies; what a copy has to match is read here, while the + // pane shows. + let paneHeader; + test("the pane's header is the IDE's tool-window header", async () => { + paneHeader = await c.evaluate(`(() => { + const h = [...document.querySelectorAll("#ADDIN_${PANE} .sectionHeader")] + .find((e) => e.textContent.trim() === "TWINBASIC HELP"); + if (!h) return null; + const s = getComputedStyle(h); + return { + height: h.getBoundingClientRect().height, + look: { backgroundColor: s.backgroundColor, color: s.color, fontSize: s.fontSize, fontWeight: s.fontWeight, paddingTop: s.paddingTop, paddingBottom: s.paddingBottom }, + }; + })()`); + assert.ok(paneHeader, "no header for the pane"); + }); + test("Open in browser opens the page the pane shows", async () => { const mark = await consoleMark(c); await click(c, { toolWindow: PANE, css: "#helpBrowser" }); @@ -721,6 +749,28 @@ scenario("the help add-in: F1 and the help pane", (lane) => { // ------------------------------------------------------------ the detached window let win; // the DevTools connection to the window's page + let firstDetach; // the console mark before the first Detach + // The window's style bits that tell a Windows title bar from none. + const WS_CAPTION = 0xc00000; + const WS_THICKFRAME = 0x40000; + const WS_VISIBLE = 0x10000000; + // The style the add-in printed for the window's frame after `mark`, as + // "window chrome style ". + async function windowFrame(mark) { + const m = (await addinLines(c, mark)).map((l) => l.match(/^window chrome (custom|native) style ([0-9A-F]+)$/)); + const last = m.filter(Boolean).pop(); + return last ? { chrome: last[1], style: Number.parseInt(last[2], 16) } : null; + } + // Wait for the add-in's line after `mark`, and return the lines. + async function waitForLine(mark, line, timeout = 10 * 1000) { + const found = await waitFor(c, async (c) => (await addinLines(c, mark)).includes(line), { timeout }); + assert.ok(found, `no "${line}" in ${JSON.stringify(await addinLines(c, mark))}`); + return addinLines(c, mark); + } + // The centre of an element of the window's page. + const centreInWindow = (css) => + win.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(css)}) + .getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); // The src of the frame in the window, from the add-in's "window page" line // after a mark. const windowPage = async (mark) => @@ -728,10 +778,9 @@ scenario("the help add-in: F1 and the help pane", (lane) => { const windowRows = () => win.evaluate(`[...document.querySelectorAll("#helpResults .hit .label")].map((e) => e.textContent)`); const windowBox = () => win.evaluate(`document.getElementById("helpHover").checked`); - // The centre of an element of the window's page, and a click there. + // A click on an element of the window's page. async function clickInWindow(css) { - const p = await win.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(css)}) - .getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`); + const p = await centreInWindow(css); await clickAt(win, p.x, p.y); } // The same theme properties in the IDE and in the window's page; the IDE @@ -779,6 +828,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { const was = await frameSrc(c); assert.ok(was, "the pane had no page to carry over"); const mark = await detach(); + firstDetach = mark; assert.equal(await waitFor(c, () => windowPage(mark), { timeout: 30 * 1000 }), was); assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is still showing"); assert.equal(await frameSrc(c), was); @@ -801,6 +851,119 @@ scenario("the help add-in: F1 and the help pane", (lane) => { ); }); + test("the window's title bar is one row, with the title and four buttons, and Windows draws no caption", async () => { + const bar = await win.evaluate(`(() => { + const el = (id) => document.getElementById(id); + const bar = el("helpTitleBar").getBoundingClientRect(); + const rects = {}; + const inRow = (id) => { + const r = el(id).getBoundingClientRect(); + rects[id] = [r.left, r.top, r.width, r.height].map(Math.round); + return r.width > 0 && r.top >= bar.top && r.bottom <= bar.bottom; + }; + const s = getComputedStyle(el("helpTitleBar")); + return { + chrome: document.documentElement.getAttribute("data-chrome"), + top: bar.top, + height: bar.height, + below: el("helpBar").getBoundingClientRect().top, + title: el("helpTitle").textContent, + inRow: ["helpGrip", "helpTitle", "helpAttach", "helpMinimize", "helpMaximize", "helpClose"].map(inRow), + rects, + attachIn: el("helpAttach").parentElement.id, + attachWord: getComputedStyle(el("helpAttach").querySelector(".label")).display, + look: { backgroundColor: s.backgroundColor, color: s.color, fontSize: s.fontSize, fontWeight: s.fontWeight, paddingTop: s.paddingTop, paddingBottom: s.paddingBottom }, + }; + })()`); + const { rects, look, height, ...rest } = bar; + assert.ok(paneHeader, "the pane's header was not read"); + assert.deepEqual( + rest, + { + chrome: "custom", + top: 0, + below: height, + title: "TWINBASIC HELP", + inRow: [true, true, true, true, true, true], + attachIn: "helpTitleBar", + attachWord: "none", + }, + JSON.stringify(rects), + ); + // The IDE's own tool-window header, read from the pane while it showed. + assert.deepEqual(look, paneHeader.look); + assert.ok( + Math.abs(height - paneHeader.height) <= 1, + `the bar is ${height} high, the IDE's header ${paneHeader.height}`, + ); + const frame = await windowFrame(firstDetach); + assert.equal(frame?.chrome, "custom"); + assert.notEqual(frame.style & WS_CAPTION, WS_CAPTION, `the window's style ${frame.style.toString(16)}`); + assert.ok(frame.style & WS_THICKFRAME, "the window cannot be resized"); + assert.ok(frame.style & WS_VISIBLE, "the window is not visible"); + }); + + // The Maximize button and the title bar's double click maximize the window + // and restore it, and the button shows which it will do. + const maximizeButton = () => + win.evaluate(`({ + state: document.documentElement.getAttribute("data-state"), + title: document.getElementById("helpMaximize").title, + glyphs: [".maximize", ".restore"].map((s) => getComputedStyle(document.querySelector("#helpMaximize " + s)).display === "none"), + })`); + + // The size of the window's page and of the monitor's work area, in the same + // units: a maximized window with no title bar of its own has to fill the + // work area, not the whole monitor over the taskbar (WindowApi, + // LimitMaximizeToWorkArea). + const placeAndWorkArea = () => + win.evaluate(`({ + window: [innerWidth, innerHeight], + work: [screen.availWidth, screen.availHeight], + })`); + + test("Maximize and Restore change the window's state, and the button shows which it will do", async () => { + let mark = await consoleMark(c); + await clickInWindow("#helpMaximize"); + await waitForLine(mark, "window state max"); + const maximized = await waitFor(c, async () => { + const p = await placeAndWorkArea(); + return p.window.every((v, i) => Math.abs(v - p.work[i]) <= 1) && p; + }); + assert.ok(maximized, `not the work area: ${JSON.stringify(await placeAndWorkArea())}`); + assert.ok( + await waitFor(c, async () => (await maximizeButton()).state === "max"), + JSON.stringify(await maximizeButton()), + ); + assert.deepEqual(await maximizeButton(), { state: "max", title: "Restore", glyphs: [true, false] }); + mark = await consoleMark(c); + await clickInWindow("#helpMaximize"); + await waitForLine(mark, "window state normal"); + assert.ok(await waitFor(c, async () => (await maximizeButton()).state === "normal")); + assert.deepEqual(await maximizeButton(), { state: "normal", title: "Maximize", glyphs: [false, true] }); + }); + + test("a double click on the bar maximizes the window, and another restores it", async () => { + const p = await centreInWindow("#helpTitle"); + for (const want of ["max", "normal"]) { + const mark = await consoleMark(c); + await clickAt(win, p.x, p.y, { clickCount: 2 }); + await waitForLine(mark, `window state ${want}`); + } + }); + + test("Minimize, and F1 brings the window back", async () => { + let mark = await consoleMark(c); + await clickInWindow("#helpMinimize"); + await waitForLine(mark, "window state min"); + await at(c, 7, 21); + mark = await consoleMark(c); + await pressKey(c, "F1"); + const want = `${pages}/tB/Modules/Strings/Len?theme=${await themeGroup(c)}&pane=1`; + await waitForLine(mark, "window state normal"); + assert.equal(await waitFor(c, () => windowPage(mark)), want); + }); + test("F1 shows the page in the window, and the pane is left alone", async () => { const pane = await frameSrc(c); await at(c, 7, 21); @@ -899,6 +1062,58 @@ scenario("the help add-in: F1 and the help pane", (lane) => { ); }); + // A press on the bar with the mouse moved a few pixels, or held for 250 ms, + // starts the system's move loop through the add-in, and a click does not. + // The page posts "drag" and the add-in prints "window drag" before it runs + // the loop, which follows the real mouse; the harness's desktop has none, + // and the window does not move under the page's synthetic mouse (its + // screenX and screenY were read before and after), so that is not asserted. + const mouse = (type, p, extra = {}) => + win.send("Input.dispatchMouseEvent", { type, x: p.x, y: p.y, button: "left", buttons: 1, ...extra }); + + test("a click or a small move on the bar starts no drag, and a larger move does", async () => { + const p = await centreInWindow("#helpTitle"); + const mark = await consoleMark(c); + await clickAt(win, p.x, p.y); + await win.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y }); + await mouse("mousePressed", p, { clickCount: 1 }); + await mouse("mouseMoved", { x: p.x + 2, y: p.y + 1 }); + await sleep(100); + await mouse("mouseReleased", { x: p.x + 2, y: p.y + 1 }, { buttons: 0 }); + await sleep(500); + assert.deepEqual(await addinLines(c, mark), [], "a click started a drag"); + await mouse("mousePressed", p, { clickCount: 1 }); + await mouse("mouseMoved", { x: p.x + 30, y: p.y + 10 }); + await waitForLine(mark, "window drag"); + await mouse("mouseReleased", { x: p.x + 30, y: p.y + 10 }, { buttons: 0 }); + }); + + test("a press held on the bar for 250 ms starts a drag", async () => { + const p = await centreInWindow("#helpTitle"); + const mark = await consoleMark(c); + await mouse("mousePressed", p, { clickCount: 1 }); + await waitForLine(mark, "window drag"); + await mouse("mouseReleased", p, { buttons: 0 }); + }); + + test("the X hides the window and the help stays detached, and F1 shows it again", async () => { + let mark = await consoleMark(c); + await clickInWindow("#helpClose"); + await waitForLine(mark, "window hidden"); + assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is showing"); + await at(c, 5, 9); + mark = await consoleMark(c); + await pressKey(c, "F1"); + const lines = await waitForLine(mark, "window shown"); + assert.equal( + await waitFor(c, () => windowPage(mark)), + `${pages}/tB/Modules/Interaction/MsgBox?theme=${await themeGroup(c)}&pane=1`, + ); + assert.ok(!lines.includes("detached") && !lines.includes("attached"), JSON.stringify(lines)); + assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is showing"); + assert.equal((await windowFrame(mark))?.chrome, "custom"); + }); + test("Attach brings the pane back with the search and the last page", async () => { await at(c, 7, 21); const mark = await consoleMark(c); @@ -935,6 +1150,86 @@ scenario("the help add-in: F1 and the help pane", (lane) => { await showsPage("/tB/Modules/Strings/Len"); }); + test("a window hidden with the X stays hidden after a compiler restart, until the Help button shows it", async () => { + await detach(); + await connectWindow(); + let mark = await consoleMark(c); + await clickInWindow("#helpClose"); + await waitForLine(mark, "window hidden"); + mark = await consoleMark(c); + await lane.restartCompiler(); + pages = await pagesOrigin(mark); + await sleep(2000); + const lines = await addinLines(c, mark); + assert.deepEqual( + lines.filter((l) => l.startsWith("window") || l === "detached"), + [], + "the window was shown again", + ); + assert.equal((await toolWindow(c, PANE))?.visible, false, "the pane is showing"); + mark = await consoleMark(c); + await click(c, "addinButton-tbDocsHelp"); + await waitForLine(mark, "window shown", 30 * 1000); + await connectWindow(); + // The search box gets the focus once the new window's page is ready. + assert.ok( + await waitFor(c, async () => (await win.evaluate(`document.activeElement?.id`)) === "helpSearch"), + "the search box has no focus in the window", + ); + mark = await consoleMark(c); + await clickInWindow("#helpAttach"); + await waitForLine(mark, "attached"); + assert.ok(await waitFor(c, async (c) => (await toolWindow(c, PANE))?.visible), "the pane is not showing"); + }); + + // The IDE's option "Show real OS titlebar" gives the window the Windows title + // bar, and the page then draws none. The harness cannot change the option + // without changing the user's own, so the add-in reads + // TB_DOCS_HELP_REAL_TITLEBAR under the test switch instead, which is the + // IDE's environment: a second IDE is started with it. + test("with the IDE's option Show real OS titlebar on, the window has the Windows title bar and Attach is in the search bar", async () => { + win?.close(); + win = null; + await lane.closeProject(); + c = await lane.open(HOST, { env: ideEnv(1) }); + pages = await pagesOrigin(null); + await click(c, "addinButton-tbDocsHelp"); + assert.ok(await waitFor(c, async (c) => (await toolWindow(c, PANE))?.visible), "the pane is not showing"); + const mark = await detach(); + const frame = await windowFrame(mark); + assert.equal(frame?.chrome, "native"); + assert.equal(frame.style & WS_CAPTION, WS_CAPTION, `the window's style ${frame.style.toString(16)}`); + assert.ok(frame.style & WS_THICKFRAME, "the window cannot be resized"); + await connectWindow(); + // The add-in tells the page which frame it has once the page is ready. + await waitFor( + c, + async () => (await win.evaluate(`document.documentElement.getAttribute("data-chrome")`)) === "native", + ); + const page = await win.evaluate(`(() => { + const el = (id) => document.getElementById(id); + return { + chrome: document.documentElement.getAttribute("data-chrome"), + bar: getComputedStyle(el("helpTitleBar")).display, + attachIn: el("helpAttach").parentElement.id, + attachWord: getComputedStyle(el("helpAttach").querySelector(".label")).display !== "none", + searchTop: el("helpBar").getBoundingClientRect().top, + order: [...el("helpBar").children].map((e) => e.id), + }; + })()`); + assert.deepEqual(page, { + chrome: "native", + bar: "none", + attachIn: "helpBar", + attachWord: true, + searchTop: 0, + order: ["helpSearch", "helpBrowser", "helpAttach", "helpHoverLabel"], + }); + const attached = await consoleMark(c); + await clickInWindow("#helpAttach"); + await waitForLine(attached, "attached"); + }); + return () => { win?.close(); server?.close(); From c589d516afa469ae84d88e112e906e0042a36e2e Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 7 Oct 2026 18:38:24 +0200 Subject: [PATCH 33/33] A maximized help window fills its monitor's work area exactly, and its WebView2 always fills the client area: the window procedure sizes the control and answers WM_GETMINMAXINFO and WM_NCCALCSIZE --- WIP.HelpAddin.md | 23 +++++- add-in/Sources/HelpAddIn.twin | 7 +- add-in/Sources/HelpWindow.tbform | 7 -- add-in/Sources/HelpWindow.twin | 5 ++ add-in/Sources/WindowApi.twin | 129 +++++++++++++++++++++++++------ test/addin/help.test.mjs | 78 +++++++++++++++---- 6 files changed, 200 insertions(+), 49 deletions(-) diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index fa1aab58b..b96ba0c2d 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -1077,10 +1077,25 @@ harness about a second against opening a new IDE. frame and no title bar over the whole monitor, taskbar included (a page of 1707x1067 against a work area of 1707x1027), so a window procedure put in front of the form's (`SetWindowLongPtrW`, as `HelpServer` does for its message window) answers - `WM_GETMINMAXINFO` with the work area, kept outside the monitor by the frame's thickness - as Windows does for a window with a title bar, and the lane checks the page fills the - work area. The procedure is taken off in `Class_Terminate`. With the Windows title bar - it is not used. + `WM_GETMINMAXINFO` with the work area exactly. A maximized window with no title bar has + no frame taken off its client area (measured at 150%: the client area is the whole window + rectangle), so the window outside the work area by the frame's thickness, which an + earlier version assumed, was shifted up and left by it and left a strip at the right and + bottom; the procedure also answers `WM_NCCALCSIZE` for a maximized window whose proposed + rectangle is the work area with the rectangle as it came, so that no Windows version + takes a frame off it. (A window being restored is still zoomed when its + `WM_NCCALCSIZE` comes, with its own smaller rectangle: only the work-area rectangle is + answered, or the client area is left the work area's size.) The form's layout does not + keep the control at the client area either: with anchors, and without them, it + leaves the control overhanging or offset by the frame once the window has been + maximized and restored (by 10 and 5 pixels at 150%). So the tbform has no anchors, and + the same procedure, which is also put in front with the Windows title bar, sizes the + control to `GetClientRect` after the window's own handling of `WM_SIZE` and + `WM_WINDOWPOSCHANGED`. The lane asks the add-in (the page posts `geometry`, under the + test switch) for the client area, the control and the work area in screen pixels, and + checks that the control is the client area, the page is its size, and, maximized, that + the client area is the work area, to the right and bottom edges as well; and the same + control check with the Windows title bar. The procedure is taken off in `Class_Terminate`. - **The X** hides the window, as the pane's X hides the pane, and the help stays detached; Attach is the way back to the pane. F1, the Help button and a hover link show the window again. F1 and a hover link do not activate it, so the editor keeps the diff --git a/add-in/Sources/HelpAddIn.twin b/add-in/Sources/HelpAddIn.twin index 490ee3a28..eb5a64ad4 100644 --- a/add-in/Sources/HelpAddIn.twin +++ b/add-in/Sources/HelpAddIn.twin @@ -123,7 +123,7 @@ Private Class HelpAddIn ' when it is attached or closed, and the window's own procedure put back. Private Sub Class_Terminate() If Not HelpWin Is Nothing Then SaveWindowPlace - EndMaximizeLimit + EndWindowHook If Not Server Is Nothing Then Server.CloseAll End Sub @@ -622,6 +622,7 @@ Private Class HelpAddIn Private Sub ApplyFrame() NativeFrame = WantsNativeFrame() HelpWin.UseNativeFrame NativeFrame + KeepControlInClientArea HelpWin.hWnd, HelpWin.ControlHandle LimitMaximizeToWorkArea HelpWin.hWnd, Not NativeFrame ApplyTitleBarTheme PostToWindow "chrome:" & IIf(NativeFrame, "native", "custom") @@ -710,6 +711,10 @@ Private Class HelpAddIn StartWindowDrag HelpWin.hWnd Case "browser" OpenInBrowser + Case "geometry" + If TestSwitchOn() Then + Host.DebugConsole.PrintText TAG & "window geometry " & WindowGeometry(HelpWin.hWnd, HelpWin.ControlHandle) + End If Case "frame" If TestSwitchOn() Then Host.DebugConsole.PrintText TAG & "window page " & payload End Select diff --git a/add-in/Sources/HelpWindow.tbform b/add-in/Sources/HelpWindow.tbform index be5e64745..b92ae4e01 100644 --- a/add-in/Sources/HelpWindow.tbform +++ b/add-in/Sources/HelpWindow.tbform @@ -21,13 +21,6 @@ "Width": 480, "_children": [ { - "Anchors": { - "Bottom": true, - "Left": true, - "Right": true, - "Top": true, - "_clsid": "{8524D4B5-72A9-40A9-A189-29E7905C40CA}" - }, "DocumentURL": "", "EnvironmentOptions": { "UserDataFolder": "", diff --git a/add-in/Sources/HelpWindow.twin b/add-in/Sources/HelpWindow.twin index 28cc26038..2344a7bbe 100644 --- a/add-in/Sources/HelpWindow.twin +++ b/add-in/Sources/HelpWindow.twin @@ -30,6 +30,11 @@ Class HelpWindow Return PageReady End Property + ' The control's window, which the tests ask the add-in to measure. + Public Property Get ControlHandle() As LongPtr + Return WebView.hWnd + End Property + ' Sends the page a message. One sent before the page is ready has nowhere ' to go, and is dropped: the add-in sends the whole state when the page ' says "ready". diff --git a/add-in/Sources/WindowApi.twin b/add-in/Sources/WindowApi.twin index 6c1f164ec..accdaa45c 100644 --- a/add-in/Sources/WindowApi.twin +++ b/add-in/Sources/WindowApi.twin @@ -66,8 +66,12 @@ Module WindowApi ByVal lpValue As LongPtr, ByVal dwFlags As Long, ByVal pdwType As LongPtr, ByVal pvData As LongPtr, _ ByRef pcbData As Long) As Long + Private Declare PtrSafe Function GetClientRect Lib "user32" (ByVal hWnd As LongPtr, ByRef lpRect As RECT) As Long + Private Declare PtrSafe Function ClientToScreen Lib "user32" (ByVal hWnd As LongPtr, ByRef lpPoint As POINTAPI) As Long + Private PreviousProc As LongPtr ' the window's own procedure, while ours is in front of it Private OverriddenWindow As LongPtr + Private ControlWindow As LongPtr ' the control that fills the window's client area Private LimitMaximize As Boolean Private Const MAX_PATH As Long = 520 @@ -85,6 +89,9 @@ Module WindowApi Private Const MONITOR_DEFAULTTONEAREST As Long = 2 Private Const GWLP_WNDPROC As Long = -4 Private Const WM_GETMINMAXINFO As Long = &H24 + Private Const WM_SIZE As Long = &H5 + Private Const WM_WINDOWPOSCHANGED As Long = &H47 + Private Const WM_NCCALCSIZE As Long = &H83 Private Const WM_NCLBUTTONDOWN As Long = &HA1 Private Const HTCAPTION As Long = 2 ' DWMWA_USE_IMMERSIVE_DARK_MODE: 20 on Windows 10 2004 and later, 19 before. @@ -204,49 +211,125 @@ Module WindowApi SetWindowPos hWnd, 0, 0, 0, 0, 0, SWP_NOMOVE Or SWP_NOSIZE Or SWP_NOZORDER Or SWP_NOACTIVATE Or SWP_FRAMECHANGED End Sub + ' Sizes a child window to its parent's whole client area, in pixels. A + ' minimized parent has no client area to fill, and is left alone. + Private Sub FillClientArea(ByVal hParent As LongPtr, ByVal hChild As LongPtr) + If IsIconic(hParent) <> 0 Then Exit Sub + Dim r As RECT + If GetClientRect(hParent, r) = 0 Then Exit Sub + MoveWindow hChild, 0, 0, r.Right, r.Bottom, 1 + End Sub + + ' Keeps a control the size of the window's client area. The form's own + ' layout places a control by the margins it had at design time, and leaves + ' it overhanging or short of the client area by what the frame takes once + ' the window has been maximized and restored; the window procedure put in + ' front of the window's own sizes the control again after the form has laid + ' it out. The procedure is put in front once, and stays until EndWindowHook. + Public Sub KeepControlInClientArea(ByVal hWnd As LongPtr, ByVal hControl As LongPtr) + ControlWindow = hControl + HookWindow hWnd + FillClientArea hWnd, hControl + End Sub + ' Windows maximizes a window that has a sizing frame and no title bar over - ' the whole monitor, taskbar included. With limit set, a window procedure - ' put in front of the window's own answers WM_GETMINMAXINFO with the - ' monitor's work area, so that it is maximized as one with a title bar is. - ' The procedure is put in front once, and stays until EndMaximizeLimit. + ' the whole monitor, taskbar included, and a little further out. With limit + ' set, the window procedure put in front of the window's own answers + ' WM_GETMINMAXINFO with the work area of the monitor the window is on, and + ' WM_NCCALCSIZE with that work area as the client area of a maximized + ' window, so that the window and its client area are the work area and + ' nothing else, whatever the frame's thickness is. Public Sub LimitMaximizeToWorkArea(ByVal hWnd As LongPtr, ByVal limit As Boolean) LimitMaximize = limit - If limit And OverriddenWindow = 0 Then - PreviousProc = SetWindowProc(hWnd, GWLP_WNDPROC, AddressOf MaximizeProc) - OverriddenWindow = hWnd - End If + If limit Then HookWindow hWnd + End Sub + + ' Puts the window procedure in front of the window's own, once. + Private Sub HookWindow(ByVal hWnd As LongPtr) + If OverriddenWindow <> 0 Then Exit Sub + PreviousProc = SetWindowProc(hWnd, GWLP_WNDPROC, AddressOf WindowProc) + OverriddenWindow = hWnd End Sub ' Puts the window's own procedure back, for the add-in to unload with. - Public Sub EndMaximizeLimit() + Public Sub EndWindowHook() If OverriddenWindow <> 0 Then SetWindowProc OverriddenWindow, GWLP_WNDPROC, PreviousProc OverriddenWindow = 0 + ControlWindow = 0 End Sub - ' The procedure LimitMaximizeToWorkArea puts in front: the window's own - ' answer to WM_GETMINMAXINFO, with the maximized position and size changed - ' to the work area of the monitor the window is on (the position is - ' relative to the monitor), and everything else as it is. - Public Function MaximizeProc(ByVal hWnd As LongPtr, ByVal uMsg As Long, ByVal wParam As LongPtr, _ + ' The procedure HookWindow puts in front. After the window's own handling of + ' a change of its size or place, the control is sized to the client area + ' again (KeepControlInClientArea). With LimitMaximize, the window's own + ' answer to WM_GETMINMAXINFO has the maximized position and size changed to + ' the work area of the monitor the window is on (the position is relative + ' to the monitor), the rest as it is. A maximized window's client area is + ' all of it: WM_NCCALCSIZE for the work area is answered here, with the + ' rectangle as it came, so that no frame is taken off a window that has + ' none to show. Everything else is the window's own. + Public Function WindowProc(ByVal hWnd As LongPtr, ByVal uMsg As Long, ByVal wParam As LongPtr, _ ByVal lParam As LongPtr) As LongPtr - MaximizeProc = CallWindowProcW(PreviousProc, hWnd, uMsg, wParam, lParam) + If LimitMaximize And uMsg = WM_NCCALCSIZE Then + If IsZoomed(hWnd) <> 0 And IsWorkArea(lParam) Then Exit Function + End If + WindowProc = CallWindowProcW(PreviousProc, hWnd, uMsg, wParam, lParam) + If uMsg = WM_SIZE Or uMsg = WM_WINDOWPOSCHANGED Then + If ControlWindow <> 0 Then FillClientArea hWnd, ControlWindow + Exit Function + End If If uMsg <> WM_GETMINMAXINFO Or Not LimitMaximize Then Exit Function Dim monitor As MONITORINFO monitor.cbSize = Len(monitor) If GetMonitorInfoW(MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), monitor) = 0 Then Exit Function Dim info As MINMAXINFO CopyMemory info, ByVal lParam, Len(info) - ' A maximized window with a sizing frame has the frame's thickness - ' outside the work area on each side, which is how far the window's own - ' answer puts it outside the monitor. - Dim frame As Long = IIf(info.ptMaxPosition.X < 0, -info.ptMaxPosition.X, 0) - info.ptMaxPosition.X = monitor.rcWork.Left - monitor.rcMonitor.Left - frame - info.ptMaxPosition.Y = monitor.rcWork.Top - monitor.rcMonitor.Top - frame - info.ptMaxSize.X = monitor.rcWork.Right - monitor.rcWork.Left + 2 * frame - info.ptMaxSize.Y = monitor.rcWork.Bottom - monitor.rcWork.Top + 2 * frame + info.ptMaxPosition.X = monitor.rcWork.Left - monitor.rcMonitor.Left + info.ptMaxPosition.Y = monitor.rcWork.Top - monitor.rcMonitor.Top + info.ptMaxSize.X = monitor.rcWork.Right - monitor.rcWork.Left + info.ptMaxSize.Y = monitor.rcWork.Bottom - monitor.rcWork.Top CopyMemory ByVal lParam, info, Len(info) End Function + ' Whether the rectangle a WM_NCCALCSIZE message's lParam starts with, the + ' window's proposed rectangle (whether wParam is set or not), is the work + ' area of the monitor it is nearest to. A window being restored is still + ' maximized when the message comes, with its own, smaller rectangle. + Private Function IsWorkArea(ByVal lParam As LongPtr) As Boolean + Dim proposed As RECT + CopyMemory proposed, ByVal lParam, Len(proposed) + Dim monitor As MONITORINFO + monitor.cbSize = Len(monitor) + If GetMonitorInfoW(MonitorFromRect(proposed, MONITOR_DEFAULTTONEAREST), monitor) = 0 Then Return False + Return proposed.Left = monitor.rcWork.Left And proposed.Top = monitor.rcWork.Top And _ + proposed.Right = monitor.rcWork.Right And proposed.Bottom = monitor.rcWork.Bottom + End Function + + ' A rectangle as "left,top,right,bottom". + Private Function RectText(ByRef r As RECT) As String + Return r.Left & "," & r.Top & "," & r.Right & "," & r.Bottom + End Function + + ' Where a window's client area is on the screen, as RectText gives it. + Private Function ClientText(ByVal hWnd As LongPtr) As String + Dim r As RECT + GetClientRect hWnd, r + Dim p As POINTAPI + ClientToScreen hWnd, p + Return (p.X) & "," & (p.Y) & "," & (p.X + r.Right) & "," & (p.Y + r.Bottom) + End Function + + ' For the tests: the window's client area, its control's rectangle and the + ' work area of the monitor the window is on, all in screen pixels, as + ' "client L,T,R,B control L,T,R,B work L,T,R,B". + Public Function WindowGeometry(ByVal hWnd As LongPtr, ByVal hControl As LongPtr) As String + Dim control As RECT + GetWindowRect hControl, control + Dim monitor As MONITORINFO + monitor.cbSize = Len(monitor) + GetMonitorInfoW MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), monitor + Return "client " & ClientText(hWnd) & " control " & RectText(control) & " work " & RectText(monitor.rcWork) + End Function + ' The IDE's option "IDE: Show real OS titlebar", showRealTitlebar in the ' JSON object its GENERAL value holds. False when the value or the property ' is not there, which is the option's default. diff --git a/test/addin/help.test.mjs b/test/addin/help.test.mjs index 257c430eb..84a8171c5 100644 --- a/test/addin/help.test.mjs +++ b/test/addin/help.test.mjs @@ -912,25 +912,63 @@ scenario("the help add-in: F1 and the help pane", (lane) => { glyphs: [".maximize", ".restore"].map((s) => getComputedStyle(document.querySelector("#helpMaximize " + s)).display === "none"), })`); - // The size of the window's page and of the monitor's work area, in the same - // units: a maximized window with no title bar of its own has to fill the - // work area, not the whole monitor over the taskbar (WindowApi, - // LimitMaximizeToWorkArea). - const placeAndWorkArea = () => - win.evaluate(`({ - window: [innerWidth, innerHeight], - work: [screen.availWidth, screen.availHeight], - })`); + // Where the window's client area, its WebView2 control and the work area of + // its monitor are, in screen pixels, which only the add-in can read (it asks + // the window, the test switch being on), and the size of the page the + // control shows, in device pixels. Each rectangle is [left, top, right, + // bottom]. + async function windowGeometry() { + const mark = await consoleMark(c); + await win.evaluate(`chrome.webview.postMessage("geometry")`); + const found = await waitFor(c, async (c) => + (await addinLines(c, mark)).find((l) => l.startsWith("window geometry ")), + ); + assert.ok(found, `the add-in did not report the window's geometry: ${JSON.stringify(await addinLines(c, mark))}`); + const g = {}; + for (const m of found.matchAll(/(client|control|work) (-?\d+),(-?\d+),(-?\d+),(-?\d+)/g)) { + g[m[1]] = m.slice(2).map(Number); + } + g.page = await win.evaluate(`[innerWidth * devicePixelRatio, innerHeight * devicePixelRatio]`); + return g; + } + + // Whether the control fills the client area exactly, and the page it shows + // fills the control, to a pixel (the page's size is rounded to CSS pixels). + // With `rect`, the client area is also that rectangle. Returns what is wrong, + // or "" when nothing is. + function geometryFault(g, rect) { + const faults = []; + if (g.control.some((v, i) => v !== g.client[i])) faults.push("the control is not the client area"); + const size = [g.client[2] - g.client[0], g.client[3] - g.client[1]]; + if (g.page.some((v, i) => Math.abs(v - size[i]) > 1)) faults.push("the page is not the client area's size"); + if (rect && g.client.some((v, i) => v !== rect[i])) faults.push("the client area is not the work area"); + return faults.length ? `${faults.join("; ")}: ${JSON.stringify(g)}` : ""; + } + + // Waits for the window's geometry to be right, which follows a change of + // its state a moment after the add-in reports the state; fails with what was + // wrong at the end. + async function assertGeometry(what, { workArea = false } = {}) { + let last; + const fits = await waitFor(c, async () => { + last = await windowGeometry(); + return geometryFault(last, workArea ? last.work : null) === ""; + }); + assert.ok(fits, `${what}: ${geometryFault(last, workArea ? last.work : null)}`); + } + test("the window's control fills its client area", async () => { + await assertGeometry("normal"); + }); + + // A maximized window with no title bar of its own has to fill the work area, + // not the whole monitor over the taskbar, with nothing cut off at its edges + // and no strip of the form showing (WindowApi, LimitMaximizeToWorkArea). test("Maximize and Restore change the window's state, and the button shows which it will do", async () => { let mark = await consoleMark(c); await clickInWindow("#helpMaximize"); await waitForLine(mark, "window state max"); - const maximized = await waitFor(c, async () => { - const p = await placeAndWorkArea(); - return p.window.every((v, i) => Math.abs(v - p.work[i]) <= 1) && p; - }); - assert.ok(maximized, `not the work area: ${JSON.stringify(await placeAndWorkArea())}`); + await assertGeometry("maximized", { workArea: true }); assert.ok( await waitFor(c, async () => (await maximizeButton()).state === "max"), JSON.stringify(await maximizeButton()), @@ -941,6 +979,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { await waitForLine(mark, "window state normal"); assert.ok(await waitFor(c, async () => (await maximizeButton()).state === "normal")); assert.deepEqual(await maximizeButton(), { state: "normal", title: "Maximize", glyphs: [false, true] }); + await assertGeometry("restored"); }); test("a double click on the bar maximizes the window, and another restores it", async () => { @@ -1225,6 +1264,17 @@ scenario("the help add-in: F1 and the help pane", (lane) => { searchTop: 0, order: ["helpSearch", "helpBrowser", "helpAttach", "helpHoverLabel"], }); + // The control fills the client area with the Windows title bar too, in the + // window's own size and maximized (the page has no button to press). + await assertGeometry("native, normal"); + let state = await consoleMark(c); + await win.evaluate(`chrome.webview.postMessage("maxrestore")`); + await waitForLine(state, "window state max"); + await assertGeometry("native, maximized"); + state = await consoleMark(c); + await win.evaluate(`chrome.webview.postMessage("maxrestore")`); + await waitForLine(state, "window state normal"); + await assertGeometry("native, restored"); const attached = await consoleMark(c); await clickInWindow("#helpAttach"); await waitForLine(attached, "attached");